Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
run: |
cd _examples/hello-world
go vet ./...
cd ../chat-console
go vet ./...
cd ../calculator
go vet ./...

Expand All @@ -35,6 +37,8 @@ jobs:
run: |
cd _examples/hello-world
go test ./...
cd ../chat-console
go test ./...
cd ../calculator
go test ./...

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ type MemoryStore interface {
}
```

Or opt into a supplied memory implementation explicitly:

```go
import "github.com/katasec/forge/memory/inmem"

agent, _ := forge.NewAgent(forge.Config{
Provider: myProvider,
Memory: inmem.New(),
})
```

### Metadata

Attach arbitrary key-value data to the context, accessible by tools and middleware:
Expand Down
31 changes: 31 additions & 0 deletions _examples/chat-console/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Chat Console

An interactive console app that shows the intended forge developer experience:

- configure an agent once with `forge.Config`
- talk to it with `Ask`
- read the latest answer with `LastText`
- keep conversation context in memory across turns

## Run

```bash
export ANTHROPIC_API_KEY=sk-ant-...
go run .
```

Use xAI's OpenAI-compatible endpoint:

```bash
export XAI_API_KEY=xai-...
go run . -provider xai
```

Use xAI Responses API with web search:

```bash
export XAI_API_KEY=xai-...
go run . -provider xai-search
```

Type `exit` or `quit` to stop.
17 changes: 17 additions & 0 deletions _examples/chat-console/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module github.com/katasec/forge/_examples/chat-console

go 1.25.6

replace github.com/katasec/forge => ../../

require github.com/katasec/forge v0.0.0-00010101000000-000000000000

require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
23 changes: 23 additions & 0 deletions _examples/chat-console/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
120 changes: 120 additions & 0 deletions _examples/chat-console/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Chat Console demonstrates the progressive forge developer experience:
// configure an agent once, then talk to it with Ask.
package main

import (
"bufio"
"context"
"flag"
"fmt"
"log"
"os"
"strings"

"github.com/katasec/forge"
"github.com/katasec/forge/memory/inmem"
"github.com/katasec/forge/provider/anthropic"
"github.com/katasec/forge/provider/openai"
"github.com/katasec/forge/provider/xai"
)

func main() {
providerFlag := flag.String("provider", "anthropic", "Provider to use: anthropic, xai, or xai-search")
flag.Parse()

provider, citations := buildProvider(*providerFlag)
agent, err := forge.NewAgent(forge.Config{
Provider: provider,
Memory: inmem.New(),
SystemPrompt: "You are a concise assistant. Keep answers short and useful.",
})
if err != nil {
log.Fatal(err)
}

runConsole(context.Background(), agent, citations)
}

func buildProvider(name string) (forge.Provider, func()) {
switch name {
case "anthropic":
key := requireEnv("ANTHROPIC_API_KEY")
return anthropic.New(key, "claude-sonnet-4-20250514"), func() {}
case "xai":
key := requireEnv("XAI_API_KEY")
return openai.New("https://api.x.ai/v1", key, "grok-3-mini"), func() {}
case "xai-search":
key := requireEnv("XAI_API_KEY")
provider := xai.New(key, "grok-4-1-fast-non-reasoning", xai.WithWebSearch())
return provider, func() { printCitations(provider.LastCitations()) }
default:
log.Fatalf("unknown provider %q; use anthropic, xai, or xai-search", name)
return nil, nil
}
}

func runConsole(ctx context.Context, agent *forge.Agent, afterAnswer func()) {
scanner := bufio.NewScanner(os.Stdin)

fmt.Println("Forge chat console")
fmt.Println("Type exit or quit to stop.")
fmt.Println()

for {
fmt.Print("You: ")
if !scanner.Scan() {
break
}

input := strings.TrimSpace(scanner.Text())
if shouldExit(input) {
break
}
if input == "" {
continue
}

resp, err := agent.Ask(ctx, input)
if err != nil {
log.Printf("agent error: %v", err)
continue
}

fmt.Printf("Assistant: %s\n", resp.LastText())
afterAnswer()
fmt.Println()
}

if err := scanner.Err(); err != nil {
log.Printf("read input: %v", err)
}
}

func shouldExit(input string) bool {
switch strings.ToLower(input) {
case "exit", "quit":
return true
default:
return false
}
}

func requireEnv(name string) string {
value := os.Getenv(name)
if value == "" {
log.Fatalf("set %s", name)
}
return value
}

func printCitations(citations []xai.Citation) {
if len(citations) == 0 {
return
}

fmt.Println()
fmt.Println("Sources:")
for i, citation := range citations {
fmt.Printf(" [%d] %s - %s\n", i+1, citation.Title, citation.URL)
}
}
16 changes: 16 additions & 0 deletions agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"testing"

"github.com/katasec/forge/memory/inmem"
)

// mockProvider is a test double that returns pre-configured responses.
Expand Down Expand Up @@ -97,6 +99,20 @@ func TestNewAgentDisableMemory(t *testing.T) {
}
}

func TestNewAgentAcceptsExplicitMemoryStore(t *testing.T) {
store := inmem.New()
agent, err := NewAgent(Config{
Provider: &mockProvider{},
Memory: store,
})
if err != nil {
t.Fatalf("NewAgent error: %v", err)
}
if agent.memory != store {
t.Fatalf("memory = %T, want explicit store", agent.memory)
}
}

func TestAgentAskPreservesDefaultConversation(t *testing.T) {
provider := &recordingProvider{
responses: []*ProviderResponse{
Expand Down
36 changes: 27 additions & 9 deletions docs/design/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@ module github.com/katasec/forge
go 1.23
```

Root package: `package forge` — all interfaces, core types, and default implementations.
Root package: `package forge` — the primary user-facing facade: `Config`, `Agent`, `Ask`, `AskIn`, core interfaces, and type aliases for common concepts.

Sub-packages hold swappable backend implementations (following the `database/sql` pattern):
Sub-packages hold focused concepts and swappable implementations. The root package re-exports the common API so developers can start with `forge.Config` and only import subpackages when they opt into a specific implementation:

```
forge/ root — interfaces, types, Agent, Config, defaults
forge/ root facade: Agent, Config, common aliases, default helpers
forge/message/ message and role types
forge/tool/ tool interface, typed Func helper, calls, results
forge/tool/registry/ tool registry implementation
forge/provider/ provider interface, requests, responses, usage, finish reasons
forge/provider/anthropic/ Anthropic Messages API provider
forge/provider/openai/ OpenAI-compatible provider (OpenAI, xAI, Together, Groq)
forge/provider/xai/ xAI Responses API provider (web search, X search, citations)
forge/memory/ memory store interface
forge/memory/inmem/ in-memory memory store
forge/executor/ tool executor interface
forge/executor/sequential/ sequential tool executor
forge/middleware/ provider middleware types
forge/metadata/ context metadata helpers
```

Future sub-packages (created when implementations exist, not preemptively):
Expand All @@ -27,6 +37,8 @@ Future sub-packages (created when implementations exist, not preemptively):
forge/memory/sqlite/ SQLite-backed MemoryStore
forge/memory/redis/ Redis-backed MemoryStore
forge/executor/concurrent/ Parallel tool executor
forge/middleware/logging/ Logging middleware
forge/middleware/retry/ Retry middleware
```

## Code Style & API Philosophy
Expand All @@ -37,11 +49,11 @@ Top-level functions should read like intent. Complex behavior should be composed

The public API should make the common path obvious before exposing the lower-level machinery. Developers should be able to start with `Ask(ctx, prompt)`, use `AskIn(ctx, conversationID, prompt)` when they need named conversations, and drop to `Run(ctx, AgentRequest{...})` only when they need full control over roles, message history, or advanced orchestration.

Package layout should follow the same rule as the code. The root package may remain a friendly facade, but implementation-heavy defaults should move toward focused packages as the library grows: agent orchestration, messages, tools, providers, memory, executors, and metadata.
Package layout follows the same rule as the code. The root package remains a friendly facade, while focused packages organize agent concepts: messages, tools, providers, memory, executors, middleware, and metadata. Developers configure an agent runtime once, then interact with it through `Ask`, `AskIn`, or `Run`.

---

## 1. Core Types (`types.go`)
## 1. Core Types (`message`, `tool`, `provider`; re-exported by root)

### Role

Expand Down Expand Up @@ -174,7 +186,7 @@ func MetadataFromContext(ctx context.Context) (Metadata, bool)

---

## 3. Tool System (`tool.go`, `registry.go`)
## 3. Tool System (`tool`, `tool/registry`; re-exported by root)

### Tool Interface

Expand Down Expand Up @@ -234,10 +246,11 @@ func (r *ToolRegistry) Definitions() []ToolDefinition
- `Register` adds tools. Duplicate names overwrite silently (last-write-wins).
- `Get` returns a tool by name.
- `Definitions` returns all registered tools as `ToolDefinition` for passing to a provider.
- The concrete implementation lives in `tool/registry`; root re-exports `ToolRegistry` and `NewToolRegistry` for the common path.

---

## 4. Executor (`executor.go`)
## 4. Executor (`executor`, `executor/sequential`; re-exported by root)

```go
type ToolExecutor interface {
Expand All @@ -262,7 +275,7 @@ func (e *SequentialExecutor) Execute(ctx context.Context, calls []ToolCall) []To

---

## 5. Memory (`memory.go`)
## 5. Memory (`memory`, `memory/inmem`; re-exported by root)

```go
type MemoryStore interface {
Expand All @@ -280,10 +293,12 @@ func NewInMemoryStore() *InMemoryStore
- `Save` replaces the entire message history for that conversation ID.
- `Clear` deletes the conversation.
- `InMemoryStore` is safe for concurrent use.
- The `MemoryStore` contract lives in `memory`; the default implementation lives in `memory/inmem`.
- Root re-exports `MemoryStore`, `InMemoryStore`, and `NewInMemoryStore` to preserve the simple `forge.Config` experience.

---

## 6. Provider & Middleware (`provider.go`, `middleware.go`)
## 6. Provider & Middleware (`provider`, `middleware`; re-exported by root)

### Provider

Expand All @@ -308,6 +323,7 @@ type Provider interface {
- `Generate` makes a single LLM call. It does **not** loop.
- The provider is responsible for translating `ProviderRequest` into the LLM's native API format and back.
- A provider error (non-nil error return) always terminates the agent loop.
- The provider contract lives in `provider`; root re-exports `Provider`, `ProviderRequest`, and `ProviderResponse`.

### Middleware

Expand Down Expand Up @@ -336,6 +352,8 @@ for i := len(middlewares) - 1; i >= 0; i-- {

Use cases: logging, token counting, rate limiting, retry, prompt injection.

The middleware contract lives in `middleware`; root re-exports `RunFunc` and `Middleware`.

---

## 7. Config & Agent (`config.go`, `agent.go`)
Expand Down
Loading
Loading