diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8df1d7f..d1eeae0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -37,7 +37,7 @@ kernel/ ├── tools/ # Tool execution: global registry with Register, Execute, List ├── session/ # Conversation management: Session interface, in-memory implementation ├── mcp/ # MCP client (skeleton) -├── kernel/ # Runtime loop + ConnectRPC composition (skeleton) +├── kernel/ # Agent runtime loop with config-driven initialization ├── rpc/ # ConnectRPC infrastructure (proto, buf configs, generated code) ├── cmd/ # Entry points (kernel, prompt-agent) ├── tests/ # Kernel-wide integration tests @@ -60,6 +60,8 @@ Level 7: orchestrate/workflows Foundation (Level 0 — depend only on core/protocol): memory, tools, session + +Level 8: kernel (depends on agent, session, memory, tools, core) ``` ## Design Principles @@ -105,6 +107,18 @@ Project knowledge artifacts stored in `.claude/context/`: Each directory has a `.archive/` subdirectory for completed documents. Directories are created on demand. +## Task Session: Documentation Review + +During a `tau:dev-workflow` task execution session, Phase 7 (Documentation) must include a review of project context documents for any revisions necessitated by the implementation. Check the following files and update any stale descriptions, statuses, or references: + +- `_project/README.md` — subsystem topology statuses, known gaps, build order descriptions +- `_project/objective.md` — sub-issue statuses +- `README.md` — subsystem descriptions +- `.claude/CLAUDE.md` — project structure, dependency hierarchy +- `.claude/skills/kernel-dev/SKILL.md` — package responsibilities, dependency hierarchy, extension patterns + +This review happens before Phase 8 (Closeout) to ensure all project documentation stays consistent with the codebase. + ## Session Continuity Plan files in `.claude/plans/` enable session continuity across machines. diff --git a/.claude/context/guides/.archive/14-kernel-runtime-loop.md b/.claude/context/guides/.archive/14-kernel-runtime-loop.md new file mode 100644 index 0000000..d61701b --- /dev/null +++ b/.claude/context/guides/.archive/14-kernel-runtime-loop.md @@ -0,0 +1,602 @@ +# 14 - Kernel Runtime Loop + +## Problem Context + +The kernel runtime loop is the central component of Objective #1 (Kernel Core Loop). It composes agent, tools, session, and memory into the observe/think/act/repeat agentic cycle. All three foundation dependencies are complete (#11 session, #12 tools, #13 memory), but two gaps block the kernel implementation: + +1. The Agent interface methods accept `prompt string` but the kernel loop needs `[]protocol.Message` for multi-turn tool-use conversations +2. The kernel must initialize from configuration following the cold start pattern — config drives subsystem creation + +## Architecture Approach + +**Agent evolution:** Conversation-based protocol methods change from `prompt string` to `messages []protocol.Message`. The agent's `initMessages` method retains its name but changes from creating messages internally to prepending the agent's system prompt to caller-provided messages. Non-conversation methods (`Embed`, `Audio`) are unchanged. + +**Subsystem configs:** Each subsystem owns a `Config` type and a config-driven constructor following the Default/Merge pattern from `core/config`. The kernel Config embeds subsystem configs and delegates initialization. + +**Cold start:** `kernel.New(*Config, ...Option)` creates all subsystems from configuration. Functional options allow tests to override config-created defaults without compromising the config-first design. + +**ToolExecutor interface:** The kernel defines `ToolExecutor` with `List()` + `Execute()` since the tools package only exposes global functions. A private `globalToolExecutor` wraps them as the default. + +## Implementation + +### Step 1: Evolve Agent Interface + +**`agent/agent.go`** — Change interface signatures and implementation. + +Change the `prompt` parameter type from `string` to `[]protocol.Message` in all 5 conversation method signatures (interface + implementation). The parameter name `prompt` is retained — only the type changes. Method bodies are unchanged since `initMessages` already receives `prompt`. + +Update `initMessages` to accept `[]protocol.Message` instead of `string`: + +```go +func (a *agent) initMessages(prompt []protocol.Message) []protocol.Message { + if a.systemPrompt == "" { + return prompt + } + result := make([]protocol.Message, 0, len(prompt)+1) + result = append(result, protocol.NewMessage(protocol.RoleSystem, a.systemPrompt)) + result = append(result, prompt...) + return result +} +``` + +### Step 2: Update Mock Agent + +**`agent/mock/agent.go`** — Same change: `prompt string` → `prompt []protocol.Message` in the 5 method signatures. Bodies are unchanged (mock doesn't use the arguments). + +### Step 3: Update prompt-agent CLI + +**`cmd/prompt-agent/main.go`** — Each execute function builds a message slice from the prompt string. The pattern is the same for all — wrap the prompt in a single user message. + +```go +func executeChat(ctx context.Context, agent agent.Agent, prompt string) { + messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} + response, err := agent.Chat(ctx, messages) + // rest unchanged +} + +func executeChatStream(ctx context.Context, agent agent.Agent, prompt string) { + messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} + stream, err := agent.ChatStream(ctx, messages) + // rest unchanged +} + +func executeVision(ctx context.Context, agent agent.Agent, prompt string, images []string) { + messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} + response, err := agent.Vision(ctx, messages, images) + // rest unchanged +} + +func executeVisionStream(ctx context.Context, agent agent.Agent, prompt string, images []string) { + messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} + stream, err := agent.VisionStream(ctx, messages, images) + // rest unchanged +} + +func executeTools(ctx context.Context, agent agent.Agent, prompt string, tools []protocol.Tool) { + messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} + response, err := agent.Tools(ctx, messages, tools) + // rest unchanged +} +``` + +### Step 4: Update Orchestrate Examples + +Every `.Chat(ctx, prompt)` call becomes `.Chat(ctx, messages)` where `messages` wraps the prompt in a user message. The pattern for each call site: + +```go +// Before +response, err := someAgent.Chat(ctx, prompt) + +// After +messages := []protocol.Message{protocol.NewMessage(protocol.RoleUser, prompt)} +response, err := someAgent.Chat(ctx, messages) +``` + +Files and call sites: + +- `orchestrate/examples/phase-01-hubs/main.go` — 4 Chat calls (lines 154, 168, 182, 196) +- `orchestrate/examples/phase-02-03-state-graphs/main.go` — 6 Chat calls (lines 92, 109, 131, 154, 172, 189) +- `orchestrate/examples/phase-04-sequential-chains/main.go` — 1 Chat call (line 164) +- `orchestrate/examples/phase-05-parallel-execution/main.go` — 1 Chat call (line 124) +- `orchestrate/examples/phase-06-checkpointing/main.go` — 4 Chat calls (lines 89, 108, 136, 155) +- `orchestrate/examples/phase-07-conditional-routing/main.go` — 2 Chat calls (lines 218, 272) +- `orchestrate/examples/darpa-procurement/workflow.go` — 8 Chat calls (lines 127, 182, 241, 320, 331, 481, 540, 598) + +Each file needs the `protocol` import added if not already present. + +### Step 5: Session Configuration + +**`session/config.go`** — new file. + +```go +package session + +type Config struct{} + +func DefaultConfig() Config { + return Config{} +} + +func (c *Config) Merge(source *Config) {} + +func New(cfg *Config) (Session, error) { + return NewMemorySession(), nil +} +``` + +### Step 6: Memory Configuration + +**`memory/config.go`** — new file. + +```go +package memory + +type Config struct { + Path string `json:"path,omitempty"` +} + +func DefaultConfig() Config { + return Config{} +} + +func (c *Config) Merge(source *Config) { + if source.Path != "" { + c.Path = source.Path + } +} + +func NewStore(cfg *Config) (Store, error) { + if cfg.Path == "" { + return nil, nil + } + return NewFileStore(cfg.Path), nil +} +``` + +### Step 7: Kernel Errors + +**`kernel/errors.go`** — new file. + +```go +package kernel + +import "errors" + +var ErrMaxIterations = errors.New("max iterations reached") +``` + +### Step 8: Kernel Implementation + +**`kernel/kernel.go`** — new file. + +```go +package kernel + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/tailored-agentic-units/kernel/agent" + "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" + "github.com/tailored-agentic-units/kernel/core/response" + "github.com/tailored-agentic-units/kernel/memory" + "github.com/tailored-agentic-units/kernel/session" + "github.com/tailored-agentic-units/kernel/tools" +) + +const defaultMaxIterations = 10 + +// --- Configuration --- + +type Config struct { + Agent config.AgentConfig `json:"agent"` + Session session.Config `json:"session"` + Memory memory.Config `json:"memory"` + MaxIterations int `json:"max_iterations,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` +} + +func DefaultConfig() Config { + return Config{ + Agent: config.DefaultAgentConfig(), + Session: session.DefaultConfig(), + Memory: memory.DefaultConfig(), + MaxIterations: defaultMaxIterations, + } +} + +func (c *Config) Merge(source *Config) { + c.Agent.Merge(&source.Agent) + c.Session.Merge(&source.Session) + c.Memory.Merge(&source.Memory) + if source.MaxIterations > 0 { + c.MaxIterations = source.MaxIterations + } + if source.SystemPrompt != "" { + c.SystemPrompt = source.SystemPrompt + } +} + +func LoadConfig(filename string) (*Config, error) { + cfg := DefaultConfig() + + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var loaded Config + if err := json.Unmarshal(data, &loaded); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + cfg.Merge(&loaded) + return &cfg, nil +} + +// --- Types --- + +type Result struct { + Response string + Iterations int + ToolCalls []ToolCallRecord +} + +type ToolCallRecord struct { + Iteration int + ID string + Name string + Arguments string + Result string + IsError bool +} + +// --- ToolExecutor --- + +type ToolExecutor interface { + List() []protocol.Tool + Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) +} + +type globalToolExecutor struct{} + +func (globalToolExecutor) List() []protocol.Tool { + return tools.List() +} + +func (globalToolExecutor) Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Execute(ctx, name, args) +} + +// --- Options --- + +type Option func(*Kernel) + +func WithAgent(a agent.Agent) Option { + return func(k *Kernel) { k.agent = a } +} + +func WithSession(s session.Session) Option { + return func(k *Kernel) { k.session = s } +} + +func WithToolExecutor(e ToolExecutor) Option { + return func(k *Kernel) { k.tools = e } +} + +func WithMemoryStore(s memory.Store) Option { + return func(k *Kernel) { k.store = s } +} + +// --- Kernel --- + +type Kernel struct { + agent agent.Agent + tools ToolExecutor + session session.Session + store memory.Store + maxIterations int + systemPrompt string +} + +func New(cfg *Config, opts ...Option) (*Kernel, error) { + a, err := agent.New(&cfg.Agent) + if err != nil { + return nil, fmt.Errorf("failed to create agent: %w", err) + } + + sess, err := session.New(&cfg.Session) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + store, err := memory.NewStore(&cfg.Memory) + if err != nil { + return nil, fmt.Errorf("failed to create memory store: %w", err) + } + + k := &Kernel{ + agent: a, + tools: globalToolExecutor{}, + session: sess, + store: store, + maxIterations: cfg.MaxIterations, + systemPrompt: cfg.SystemPrompt, + } + + for _, opt := range opts { + opt(k) + } + + return k, nil +} + +func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { + k.session.AddMessage(protocol.NewMessage(protocol.RoleUser, prompt)) + + systemContent := k.buildSystemContent(ctx) + + result := &Result{} + + for iteration := range k.maxIterations { + if err := ctx.Err(); err != nil { + return result, err + } + + messages := k.buildMessages(systemContent) + + resp, err := k.agent.Tools(ctx, messages, k.tools.List()) + if err != nil { + return result, fmt.Errorf("agent call failed: %w", err) + } + + if len(resp.Choices) == 0 { + return result, fmt.Errorf("agent returned empty response") + } + + choice := resp.Choices[0] + + if len(choice.Message.ToolCalls) == 0 { + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleAssistant, + Content: choice.Message.Content, + }) + result.Response = choice.Message.Content + result.Iterations = iteration + 1 + return result, nil + } + + // Assistant message with tool calls + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleAssistant, + Content: choice.Message.Content, + ToolCalls: convertToolCalls(choice.Message.ToolCalls), + }) + + // Execute each tool call + for _, tc := range choice.Message.ToolCalls { + record := ToolCallRecord{ + Iteration: iteration + 1, + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + + toolResult, toolErr := k.tools.Execute(ctx, tc.Function.Name, json.RawMessage(tc.Function.Arguments)) + + if toolErr != nil { + errContent := fmt.Sprintf("error: %s", toolErr) + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleTool, + Content: errContent, + ToolCallID: tc.ID, + }) + record.Result = errContent + record.IsError = true + } else { + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleTool, + Content: toolResult.Content, + ToolCallID: tc.ID, + }) + record.Result = toolResult.Content + record.IsError = toolResult.IsError + } + + result.ToolCalls = append(result.ToolCalls, record) + } + + result.Iterations = iteration + 1 + } + + return result, ErrMaxIterations +} + +// buildSystemContent combines kernel system prompt with memory context. +func (k *Kernel) buildSystemContent(ctx context.Context) string { + content := k.systemPrompt + + if k.store == nil { + return content + } + + keys, err := k.store.List(ctx) + if err != nil || len(keys) == 0 { + return content + } + + entries, err := k.store.Load(ctx, keys...) + if err != nil { + return content + } + + for _, entry := range entries { + content += "\n\n" + string(entry.Value) + } + + return content +} + +// buildMessages constructs the full message array: [system] + session history. +func (k *Kernel) buildMessages(systemContent string) []protocol.Message { + sessionMsgs := k.session.Messages() + + if systemContent == "" { + return sessionMsgs + } + + messages := make([]protocol.Message, 0, len(sessionMsgs)+1) + messages = append(messages, protocol.NewMessage(protocol.RoleSystem, systemContent)) + messages = append(messages, sessionMsgs...) + return messages +} + +func convertToolCalls(toolCalls []response.ToolCall) []protocol.ToolCall { + result := make([]protocol.ToolCall, len(toolCalls)) + for i, tc := range toolCalls { + result[i] = protocol.ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + } + return result +} +``` + +### Step 9: Consolidate ToolCall Types + +Eliminate `response.ToolCall` and `response.ToolCallFunction` in favor of `protocol.ToolCall` as the single canonical type. Add a custom unmarshaler to handle the nested JSON format that LLM APIs return. + +#### 9a. `core/protocol/message.go` — Custom UnmarshalJSON + +Add an unmarshaler on `ToolCall` that handles the nested API response format (`{"id", "type", "function": {"name", "arguments"}}`) and flattens it into the canonical `{ID, Name, Arguments}`: + +```go +func (tc *ToolCall) UnmarshalJSON(data []byte) error { + var nested struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + if err := json.Unmarshal(data, &nested); err != nil { + return err + } + + // Nested format: flatten function fields + if nested.Function.Name != "" { + tc.ID = nested.ID + tc.Name = nested.Function.Name + tc.Arguments = nested.Function.Arguments + return nil + } + + // Flat format: decode directly + type plain ToolCall + return json.Unmarshal(data, (*plain)(tc)) +} +``` + +#### 9b. `core/response/tools.go` — Remove ToolCall types, use protocol.ToolCall + +Delete `ToolCall` and `ToolCallFunction` types. Update `ToolsResponse` to use `protocol.ToolCall`. Add `protocol` import. + +The `ToolsResponse.Choices[].Message.ToolCalls` field changes from `[]ToolCall` to `[]protocol.ToolCall`. The custom unmarshaler on `protocol.ToolCall` handles the nested JSON transparently — `ParseTools` needs no changes. + +#### 9c. `kernel/kernel.go` — Remove convertToolCalls, simplify Run + +Delete the `convertToolCalls` function and the `core/response` import. In `Run()`, use `protocol.ToolCall` fields directly: + +- `tc.Function.Name` → `tc.Name` +- `tc.Function.Arguments` → `tc.Arguments` +- `convertToolCalls(choice.Message.ToolCalls)` → `choice.Message.ToolCalls` (used directly) + +#### 9d. `cmd/prompt-agent/main.go` — Flatten field access + +```go +// Before +fmt.Printf(" - %s(%s)\n", toolCall.Function.Name, toolCall.Function.Arguments) + +// After +fmt.Printf(" - %s(%s)\n", toolCall.Name, toolCall.Arguments) +``` + +#### 9e. `agent/mock/helpers.go` — Use protocol.ToolCall in helpers + +`NewToolsAgent` parameter and inline struct types change from `response.ToolCall` to `protocol.ToolCall`. Same for `NewMultiProtocolAgent`. Replace `response` import with `protocol` where it was the only usage. + +```go +func NewToolsAgent(id string, toolCalls []protocol.ToolCall) *MockAgent { +``` + +All inline struct literals for `ToolCalls` fields change from `[]response.ToolCall` to `[]protocol.ToolCall`. + +### Step 10: Propagate Memory Errors + +`buildSystemContent` currently silences memory failures. If a store is configured and fails, `Run` should not proceed without the context it was supposed to have. + +#### 10a. `kernel/kernel.go` — `buildSystemContent` returns an error + +```go +func (k *Kernel) buildSystemContent(ctx context.Context) (string, error) { + content := k.systemPrompt + + if k.store == nil { + return content, nil + } + + keys, err := k.store.List(ctx) + if err != nil { + return "", fmt.Errorf("failed to list memory keys: %w", err) + } + + if len(keys) == 0 { + return content, nil + } + + entries, err := k.store.Load(ctx, keys...) + if err != nil { + return "", fmt.Errorf("failed to load memory entries: %w", err) + } + + for _, entry := range entries { + content += "\n\n" + string(entry.Value) + } + + return content, nil +} +``` + +#### 10b. `kernel/kernel.go` — `Run` propagates the error + +Update the `buildSystemContent` call site in `Run`: + +```go +systemContent, err := k.buildSystemContent(ctx) +if err != nil { + return result, err +} +``` + +## Validation Criteria + +- [ ] `Kernel` struct with config-driven constructor in `kernel/kernel.go` +- [ ] `Config`, `DefaultConfig`, `Merge`, `LoadConfig` follow `core/config` patterns +- [ ] Subsystem configs (`session.Config`, `memory.Config`) with config-driven constructors +- [ ] `Run()` implements observe/think/act/repeat cycle +- [ ] Loop terminates when LLM returns no tool calls (final answer) +- [ ] Loop terminates when MaxIterations reached (returns `ErrMaxIterations` + partial result) +- [ ] Context cancellation stops the loop +- [ ] Tool execution errors are reported to LLM, not fatal +- [ ] Agent interface methods accept `[]protocol.Message` instead of `prompt string` +- [ ] Mock agent and all callers updated for new signatures +- [ ] Functional options allow test overrides of config-created subsystems +- [ ] `protocol.ToolCall` is the single canonical ToolCall type — `response.ToolCall` and `response.ToolCallFunction` deleted +- [ ] `protocol.ToolCall.UnmarshalJSON` handles nested API format +- [ ] Memory load failures in `buildSystemContent` propagate as errors from `Run()` +- [ ] `go vet ./...` passes diff --git a/.claude/context/sessions/14-kernel-runtime-loop.md b/.claude/context/sessions/14-kernel-runtime-loop.md new file mode 100644 index 0000000..5421b65 --- /dev/null +++ b/.claude/context/sessions/14-kernel-runtime-loop.md @@ -0,0 +1,70 @@ +# 14 - Kernel Runtime Loop + +## Summary + +Implemented the single-agent runtime loop composing agent, tools, session, and memory into the observe/think/act/repeat agentic cycle. The kernel initializes from configuration via `New(*Config, ...Option)` following the cold start pattern — config drives subsystem creation, functional options enable test overrides. `Run()` executes the loop: add user prompt to session, build system content from memory, call `agent.Tools()`, execute any tool calls, repeat until the agent produces a final response or iterations are exhausted. + +Additionally evolved the Agent interface (conversation methods accept `[]protocol.Message` instead of `prompt string`), consolidated `response.ToolCall` into `protocol.ToolCall` with a custom `UnmarshalJSON` for transparent nested-to-flat conversion, added subsystem configurations for session and memory, and introduced `protocol.InitMessages` as a convenience wrapper. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Agent method signatures | `prompt []protocol.Message` | Enables multi-turn conversations; kernel passes full session history | +| ToolCall consolidation | Single `protocol.ToolCall` with custom `UnmarshalJSON` | Eliminates duplicate types; nested LLM format handled transparently | +| Kernel initialization | Config-driven cold start with functional options | Follows agent-lab pattern; callers never construct dependencies manually | +| ToolExecutor interface | Kernel-local interface wrapping global `tools` package | Testability without changing tools package API | +| Memory error handling | Propagate errors from `buildSystemContent` | Silent failures hide real problems; callers should know | +| `initMessages` naming | Kept original name vs. `prependSystemPrompt` | Developer preference; clearer intent in context | + +## Files Modified + +### New files +- `kernel/kernel.go` — Kernel struct, types, ToolExecutor, options, New(), Run() +- `kernel/config.go` — Config, DefaultConfig, Merge, LoadConfig +- `kernel/errors.go` — ErrMaxIterations +- `kernel/kernel_test.go` — 14 tests (93.8% coverage) +- `kernel/config_test.go` — 5 tests +- `session/config.go` — Config, DefaultConfig, Merge, New +- `session/config_test.go` — 3 tests +- `memory/config.go` — Config, DefaultConfig, Merge, NewStore +- `memory/config_test.go` — 5 tests + +### Modified files +- `core/protocol/message.go` — ToolCall with UnmarshalJSON, InitMessages +- `core/protocol/protocol_test.go` — 6 new tests (UnmarshalJSON + InitMessages) +- `core/response/tools.go` — Uses protocol.ToolCall, removed ToolCall/ToolCallFunction types +- `core/response/response_test.go` — Updated for flat ToolCall +- `agent/agent.go` — Interface methods accept []protocol.Message +- `agent/agent_test.go` — Updated call sites +- `agent/mock/agent.go` — Updated method signatures +- `agent/mock/agent_test.go` — Updated call sites +- `agent/mock/helpers.go` — Uses protocol.ToolCall +- `agent/client/client_test.go` — Updated for protocol.ToolCall + InitMessages +- `agent/providers/base_test.go` — InitMessages adoption +- `agent/providers/ollama_test.go` — InitMessages adoption +- `agent/providers/azure_test.go` — InitMessages adoption +- `cmd/prompt-agent/main.go` — InitMessages adoption +- `orchestrate/examples/` — InitMessages adoption across 7 example files + +### Infrastructure files +- `_project/README.md` — Removed Known Gaps, updated subsystem statuses +- `README.md` — Updated subsystem descriptions +- `.claude/CLAUDE.md` — Updated structure, hierarchy, added doc review directive +- `.claude/skills/kernel-dev/SKILL.md` — Added kernel package, updated hierarchy + +## Patterns Established + +- **Config-driven cold start**: `kernel.New(*Config, ...Option)` — config creates all subsystems, options override for tests +- **`protocol.InitMessages`**: Convenience wrapper replacing verbose `[]protocol.Message{protocol.NewMessage(...)}` pattern +- **Custom UnmarshalJSON on protocol types**: Transparent format conversion at the deserialization boundary +- **Subsystem Config pattern**: Each subsystem owns `Config`, `DefaultConfig()`, `Merge()`, and a config-driven constructor + +## Validation Results + +- `go vet ./...` — pass +- `go test ./...` — all pass (0 failures) +- `go mod tidy` — no changes +- Kernel package coverage: 93.8% (Run: 100%, config: 100%) +- Protocol package coverage: 92.0% +- Session package coverage: 100% diff --git a/.claude/plans/delegated-drifting-sphinx.md b/.claude/plans/delegated-drifting-sphinx.md new file mode 100644 index 0000000..09a0b7b --- /dev/null +++ b/.claude/plans/delegated-drifting-sphinx.md @@ -0,0 +1,179 @@ +# Plan: Issue #14 — Kernel Runtime Loop + +## Context + +The kernel runtime loop composes agent, tools, session, and memory into the observe/think/act/repeat agentic cycle. All three foundation dependencies are complete (#11 session, #12 tools, #13 memory). + +Two gaps must be resolved: (1) the `Agent` interface methods accept `prompt string` but the kernel loop needs `[]protocol.Message` for multi-turn tool-use conversations, and (2) the kernel must initialize from configuration following the cold start pattern established in agent-lab — config drives subsystem creation, not the caller. + +## Approach + +Two parts: (1) evolve the Agent interface, (2) implement the config-driven kernel runtime. + +### Part 1: Agent Interface Evolution + +**What changes:** Conversation-based protocol methods (`Chat`, `ChatStream`, `Vision`, `VisionStream`, `Tools`) change their first content parameter from `prompt string` to `messages []protocol.Message`. Non-conversation methods (`Embed`, `Audio`) remain unchanged. + +**System prompt behavior:** `initMessages()` is renamed to `prependSystemPrompt()`. It prepends the agent's configured system prompt (if any) to the caller-provided messages. Callers gain full message control while agents retain their identity prompt. + +**Files modified:** + +| File | Change | +|------|--------| +| `agent/agent.go` | Interface signatures + implementation; rename `initMessages` → `prependSystemPrompt` | +| `agent/mock/agent.go` | Mock method signatures | +| `agent/mock/helpers.go` | No changes needed (helpers configure responses, not call sites) | +| `cmd/prompt-agent/main.go` | Build message slices from CLI prompt string | +| `orchestrate/examples/**/*.go` | Update `.Chat(ctx, prompt)` → `.Chat(ctx, messages)` call sites | + +### Part 2: Subsystem Configurations + +Each subsystem owns its configuration and provides a config-driven constructor. This establishes extension points and follows the agent-lab cold start pattern — systems initialize from their own configs. + +**New files:** + +| File | Contents | +|------|----------| +| `session/config.go` | `Config` type, `DefaultConfig()`, `Config.Merge()` | +| `memory/config.go` | `Config` type, `DefaultConfig()`, `Config.Merge()`, `NewStore()` | + +#### `session.Config` + +```go +type Config struct { + // Extension point for future session backends. + // Currently only in-memory sessions are supported. +} + +func DefaultConfig() Config +func (c *Config) Merge(source *Config) +func New(cfg *Config) (Session, error) // returns NewMemorySession() for now +``` + +#### `memory.Config` + +```go +type Config struct { + Path string `json:"path,omitempty"` // FileStore root; empty = no memory +} + +func DefaultConfig() Config +func (c *Config) Merge(source *Config) +func NewStore(cfg *Config) (Store, error) // returns nil Store if Path empty +``` + +### Part 3: Kernel Runtime Loop + +**New files in `kernel/`:** + +| File | Contents | +|------|----------| +| `kernel.go` | `Kernel` struct, `Config`, `Result`, `ToolCallRecord`, `ToolExecutor` interface, `Option` type, `New()`, `Run()` | +| `errors.go` | `ErrMaxIterations` | + +#### Configuration (Cold Start) + +The kernel initializes purely from configuration. `New(*Config, ...Option)` delegates to each subsystem's config-driven constructor — callers never construct dependencies manually. + +```go +type Config struct { + Agent config.AgentConfig `json:"agent"` + Session session.Config `json:"session"` + Memory memory.Config `json:"memory"` + MaxIterations int `json:"max_iterations,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` +} +``` + +Config lifecycle (matching `core/config` patterns): +- `DefaultConfig()` — sensible defaults (e.g., `MaxIterations: 10`), calls subsystem `DefaultConfig()` functions +- `Config.Merge(*Config)` — delegates to each subsystem's `Merge`, overwrites non-zero kernel fields +- `LoadConfig(filename string) (*Config, error)` — load JSON, merge with defaults + +Cold start in `New`: +1. Create agent from `cfg.Agent` via `agent.New()` +2. Create session from `cfg.Session` via `session.New()` +3. Create memory store from `cfg.Memory` via `memory.NewStore()` (returns nil if path empty) +4. Default tool executor wraps the global `tools` package +5. Store config values (`MaxIterations`, `SystemPrompt`) + +#### Functional Options (Test Overrides) + +Options allow tests to override config-created subsystems: + +```go +type Option func(*Kernel) + +WithAgent(a agent.Agent) Option +WithSession(s session.Session) Option +WithToolExecutor(e ToolExecutor) Option +WithMemoryStore(s memory.Store) Option +``` + +Applied after cold start — overrides replace the config-created defaults. + +#### ToolExecutor Interface + +The tools package exposes global functions, not an instantiable type. The kernel defines a `ToolExecutor` interface for testability: + +```go +type ToolExecutor interface { + List() []protocol.Tool + Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) +} +``` + +A private `globalToolExecutor` struct wraps `tools.List()` and `tools.Execute()` as the default implementation created during cold start. + +#### Run() Loop + +``` +Run(ctx context.Context, prompt string) (*Result, error) + +1. Add user prompt as message to session +2. Load memory context (if store != nil) and build system message +3. Loop: + a. Build messages: [system] + session.Messages() + b. Call agent.Tools(ctx, messages, executor.List()) + c. If response has tool calls: + - Append assistant message (with tool calls) to session + - For each tool call: execute via executor, append tool result to session + - Record ToolCallRecord entries + - Increment iteration, check MaxIterations → return ErrMaxIterations + partial Result + - Check ctx.Err() → return context error + - Continue loop + d. If no tool calls (final answer): + - Append assistant message to session + - Return Result +``` + +#### Types + +```go +type Result struct { + Response string + Iterations int + ToolCalls []ToolCallRecord +} + +type ToolCallRecord struct { + Iteration int + ID string + Name string + Arguments string + Result string + IsError bool +} +``` + +#### Error Handling + +- `ErrMaxIterations` — returned alongside partial `Result` +- Context cancellation — checked at loop top, returns `ctx.Err()` +- Tool execution errors — reported to LLM as tool result message (not fatal) +- Agent errors — returned immediately (unrecoverable) + +#### Response Type Mapping + +- `response.ToolCall` (nested `Function.Name`/`Function.Arguments`) → `protocol.ToolCall` (flat `Name`/`Arguments`) for session messages +- Tool result messages use `protocol.RoleTool` with `ToolCallID` diff --git a/.claude/skills/kernel-dev/SKILL.md b/.claude/skills/kernel-dev/SKILL.md index 3a9d8f9..24f35dd 100644 --- a/.claude/skills/kernel-dev/SKILL.md +++ b/.claude/skills/kernel-dev/SKILL.md @@ -13,9 +13,9 @@ description: > - Adding new LLM providers or protocol support to `agent/` - Adding new workflow patterns, observers, or state graph extensions to `orchestrate/` -- Adding implementation to skeleton packages (mcp, kernel) +- Adding implementation to skeleton packages (mcp) - Extending the memory context pipeline (Store backends, skill loading, agent profiles) -- Implementing the kernel runtime loop +- Extending the kernel runtime loop or adding ConnectRPC service implementation - Architectural decisions affecting package boundaries - Writing tests for any kernel package @@ -39,6 +39,8 @@ Level 9: orchestrate/workflows (depends on Level 5-8) Foundation (Level 0 — depend only on core/protocol): memory, tools, session + +Level 10: kernel (depends on agent, session, memory, tools, core) ``` Dependencies only flow downward. Never import a higher-level package from a lower-level one. @@ -65,6 +67,7 @@ Dependencies only flow downward. Never import a higher-level package from a lowe | `memory` | Context composition pipeline | `Store`, `Cache`, `Entry`, `NewFileStore`, `NewCache` | | `tools` | Tool execution and registry | `Handler`, `Result`, `Register`, `Execute`, `List` | | `session` | Conversation management | `Session`, `NewMemorySession` | +| `kernel` | Agent runtime loop | `Kernel`, `Config`, `Result`, `ToolExecutor` | ## Extension Patterns diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bedd80..ad79587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v0.1.0-dev.1.14 + +### kernel + +- Add `Kernel` runtime loop with config-driven initialization and observe/think/act/repeat cycle (#14) +- Add `Config`, `DefaultConfig`, `Merge`, `LoadConfig` for kernel configuration (#14) +- Add `Result`, `ToolCallRecord`, `ToolExecutor` interface, and functional options (#14) +- Add `ErrMaxIterations` for loop budget exhaustion (#14) + +### core + +- Consolidate `response.ToolCall` into `protocol.ToolCall` with custom `UnmarshalJSON` for nested LLM format (#14) +- Add `protocol.InitMessages` convenience wrapper for single-prompt message initialization (#14) +- Evolve `Agent` interface: conversation methods accept `[]protocol.Message` instead of `prompt string` (#14) + +### session + +- Add `Config`, `DefaultConfig`, `Merge`, `New` for config-driven session creation (#14) + +### memory + +- Add `Config`, `DefaultConfig`, `Merge`, `NewStore` for config-driven store creation (#14) + ## v0.1.0-dev.1.13 ### memory diff --git a/README.md b/README.md index 9da694b..bac270f 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ github.com/tailored-agentic-units/kernel | `core/` | Foundational type vocabulary: protocol constants, response types, configuration, model | | `agent/` | LLM communication: agent interface, HTTP client, providers (Ollama, Azure), request construction | | `orchestrate/` | Multi-agent coordination: hubs, messaging, state graphs, workflow patterns, observability | -| `memory/` | Context composition pipeline: persistent memory, skills, agent profiles (under development) | -| `tools/` | Tool execution and registry (under development) | -| `session/` | Conversation history management (under development) | +| `memory/` | Context composition: Store interface, FileStore, session-scoped Cache | +| `tools/` | Tool execution: global registry with Register, Execute, List | +| `session/` | Conversation management: Session interface, in-memory implementation | | `mcp/` | Model Context Protocol client (under development) | -| `kernel/` | Runtime loop and ConnectRPC composition (under development) | +| `kernel/` | Agent runtime loop with config-driven initialization | ## ConnectRPC Interface diff --git a/_project/README.md b/_project/README.md index 550fe1f..93d9b61 100644 --- a/_project/README.md +++ b/_project/README.md @@ -42,12 +42,12 @@ Extension ecosystem (external services connecting through the interface): | **core** | Foundational types: Protocol, Message, Response, Config, Model | uuid | Complete | | **agent** | LLM client: Agent, Client, Provider, Request, Mock | core | Complete | | **orchestrate** | Coordination: Hub, State, Workflows, Observability, Checkpoint | agent | Complete | -| **memory** | Persistent memory: bootstrap loading, working memory, structured notes | *(none)* | Skeleton | -| **tools** | Tool system: execution interface, registry, permissions, built-in tools | core | Skeleton | -| **session** | Conversation management: message history, context window, compaction | core | Interface + in-memory | +| **memory** | Persistent memory: Store interface, FileStore, Cache | *(none)* | Complete | +| **tools** | Tool system: global registry with Register, Execute, List | core | Complete | +| **session** | Conversation management: Session interface, in-memory implementation | core | Complete | | **skills** | Progressive disclosure: SKILL.md discovery, loading, matching | memory | Skeleton | | **mcp** | MCP client: transport abstraction, tool discovery, stdio/SSE | tools | Skeleton | -| **kernel** | Agent runtime: agentic loop, plan mode, environment, composition | all above | Skeleton | +| **kernel** | Agent runtime: agentic loop, config-driven initialization | all above | Runtime loop | ## Dependency Hierarchy @@ -80,7 +80,7 @@ Key properties: These subsystems can be built in parallel (no cross-dependencies): 1. **memory** — Filesystem-based persistent memory with zero internal dependencies. Bootstrap loading, working memory, structured notes. -2. **tools** — Tool execution interface, registry, permissions, built-in tools. Depends on core for `response.ToolCall` type only. +2. **tools** — Tool execution interface, registry, permissions, built-in tools. Depends on core for `protocol.Tool` type only. 3. **session** — Conversation history management with token tracking and compaction. Depends on core for `protocol.Message` type only. ### Phase 2 — Integration (builds on Phase 1) @@ -161,12 +161,6 @@ Models with strong reasoning but limited tool calling: - **Versioning**: Phase target `v..`, dev pre-release `v-dev..` - **Package boundaries**: Enforced by Go's import rules and type system — no repository walls needed -## Known Gaps - -One limitation identified in the current codebase, deferred to per-subsystem concept sessions: - -1. **Agent methods create fresh messages** — The `Agent` interface methods accept a `prompt string` and internally create a fresh message list. Incompatible with multi-turn conversations where full history must be passed. Deferred to agent subsystem redesign. - ## Principles - Each subsystem has a single clear responsibility diff --git a/agent/agent.go b/agent/agent.go index 6536ac4..5c8f3f6 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -40,25 +40,25 @@ type Agent interface { // Chat executes a chat protocol request with optional system prompt injection. // Returns the parsed chat response or an error. - Chat(ctx context.Context, prompt string, opts ...map[string]any) (*response.ChatResponse, error) + Chat(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (*response.ChatResponse, error) // ChatStream executes a streaming chat protocol request. // Automatically sets stream: true in options. // Returns a channel of streaming chunks or an error. - ChatStream(ctx context.Context, prompt string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) + ChatStream(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (<-chan *response.StreamingChunk, error) // Vision executes a vision protocol request with images. // Images can be URLs or base64-encoded data URIs. // Returns the parsed chat response or an error. - Vision(ctx context.Context, prompt string, images []string, opts ...map[string]any) (*response.ChatResponse, error) + Vision(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (*response.ChatResponse, error) // VisionStream executes a streaming vision protocol request with images. // Returns a channel of streaming chunks or an error. - VisionStream(ctx context.Context, prompt string, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) + VisionStream(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) // Tools executes a tools protocol request with function definitions. // Returns the parsed tools response with tool calls or an error. - Tools(ctx context.Context, prompt string, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) + Tools(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) // Embed executes an embeddings protocol request. // Returns the parsed embeddings response or an error. @@ -123,7 +123,7 @@ func (a *agent) Model() *model.Model { // Initializes messages with system prompt (if configured) and user prompt. // Merges model's configured chat options with runtime opts. // Returns parsed ChatResponse or error. -func (a *agent) Chat(ctx context.Context, prompt string, opts ...map[string]any) (*response.ChatResponse, error) { +func (a *agent) Chat(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (*response.ChatResponse, error) { messages := a.initMessages(prompt) options := a.mergeOptions(protocol.Chat, opts...) @@ -146,7 +146,7 @@ func (a *agent) Chat(ctx context.Context, prompt string, opts ...map[string]any) // Merges model's configured chat options with runtime opts. // Automatically sets stream: true in options. // Returns a channel of StreamingChunk or error. -func (a *agent) ChatStream(ctx context.Context, prompt string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { +func (a *agent) ChatStream(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { messages := a.initMessages(prompt) options := a.mergeOptions(protocol.Chat, opts...) options["stream"] = true @@ -161,7 +161,7 @@ func (a *agent) ChatStream(ctx context.Context, prompt string, opts ...map[strin // Merges model's configured vision options with runtime opts. // Extracts vision_options from opts if present, separating them from model options. // Returns parsed ChatResponse or error. -func (a *agent) Vision(ctx context.Context, prompt string, images []string, opts ...map[string]any) (*response.ChatResponse, error) { +func (a *agent) Vision(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (*response.ChatResponse, error) { messages := a.initMessages(prompt) options := a.mergeOptions(protocol.Vision, opts...) @@ -194,7 +194,7 @@ func (a *agent) Vision(ctx context.Context, prompt string, images []string, opts // Extracts vision_options from opts if present, separating them from model options. // Automatically sets stream: true in options. // Returns a channel of StreamingChunk or error. -func (a *agent) VisionStream(ctx context.Context, prompt string, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { +func (a *agent) VisionStream(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { messages := a.initMessages(prompt) options := a.mergeOptions(protocol.Vision, opts...) options["stream"] = true @@ -217,7 +217,7 @@ func (a *agent) VisionStream(ctx context.Context, prompt string, images []string // Converts protocol.Tool structs to providers.ToolDefinition format. // Merges model's configured tools options with runtime opts. // Returns parsed ToolsResponse with tool calls or error. -func (a *agent) Tools(ctx context.Context, prompt string, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { +func (a *agent) Tools(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { messages := a.initMessages(prompt) options := a.mergeOptions(protocol.Tools, opts...) @@ -299,17 +299,12 @@ func (a *agent) mergeOptions(proto protocol.Protocol, opts ...map[string]any) ma return options } -// initMessages creates the initial message list with optional system prompt. -// If system prompt is configured, it's added as the first message. -// User prompt is always added after system prompt. -func (a *agent) initMessages(prompt string) []protocol.Message { - messages := make([]protocol.Message, 0) - - if a.systemPrompt != "" { - messages = append(messages, protocol.NewMessage(protocol.RoleSystem, a.systemPrompt)) +func (a *agent) initMessages(prompt []protocol.Message) []protocol.Message { + if a.systemPrompt == "" { + return prompt } - - messages = append(messages, protocol.NewMessage(protocol.RoleUser, prompt)) - - return messages + result := make([]protocol.Message, 0, len(prompt)+1) + result = append(result, protocol.NewMessage(protocol.RoleSystem, a.systemPrompt)) + result = append(result, prompt...) + return result } diff --git a/agent/agent_test.go b/agent/agent_test.go index fbddcc4..cbe9484 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -147,7 +147,7 @@ func TestAgent_Chat(t *testing.T) { t.Fatalf("New failed: %v", err) } - resp, err := a.Chat(context.Background(), "Hello") + resp, err := a.Chat(context.Background(), protocol.InitMessages(protocol.RoleUser, "Hello")) if err != nil { t.Fatalf("Chat failed: %v", err) } @@ -212,7 +212,7 @@ func TestAgent_Vision(t *testing.T) { } images := []string{"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="} - resp, err := a.Vision(context.Background(), "What's in this image?", images) + resp, err := a.Vision(context.Background(), protocol.InitMessages(protocol.RoleUser, "What's in this image?"), images) if err != nil { t.Fatalf("Vision failed: %v", err) } @@ -236,7 +236,7 @@ func TestAgent_Tools(t *testing.T) { Message struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` }{ @@ -244,18 +244,15 @@ func TestAgent_Tools(t *testing.T) { Message: struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Role: "assistant", Content: "", - ToolCalls: []response.ToolCall{ + ToolCalls: []protocol.ToolCall{ { - ID: "call_123", - Type: "function", - Function: response.ToolCallFunction{ - Name: "get_weather", - Arguments: `{"location":"Boston"}`, - }, + ID: "call_123", + Name: "get_weather", + Arguments: `{"location":"Boston"}`, }, }, }, @@ -308,7 +305,7 @@ func TestAgent_Tools(t *testing.T) { }, } - resp, err := a.Tools(context.Background(), "What's the weather in Boston?", tools) + resp, err := a.Tools(context.Background(), protocol.InitMessages(protocol.RoleUser, "What's the weather in Boston?"), tools) if err != nil { t.Fatalf("Tools failed: %v", err) } @@ -326,8 +323,8 @@ func TestAgent_Tools(t *testing.T) { } toolCall := resp.Choices[0].Message.ToolCalls[0] - if toolCall.Function.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") + if toolCall.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") } } diff --git a/agent/client/client_test.go b/agent/client/client_test.go index ada9a8d..6893818 100644 --- a/agent/client/client_test.go +++ b/agent/client/client_test.go @@ -82,9 +82,7 @@ func TestClient_Execute_Chat(t *testing.T) { c := client.New(cfg) // Create request - messages := []protocol.Message{ - protocol.NewMessage("user", "Hello"), - } + messages := protocol.InitMessages("user", "Hello") req := request.NewChat(provider, mdl, messages, map[string]any{}) // Execute @@ -114,7 +112,7 @@ func TestClient_Execute_Tools(t *testing.T) { Message struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` }{ @@ -122,18 +120,15 @@ func TestClient_Execute_Tools(t *testing.T) { Message: struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Role: "assistant", Content: "", - ToolCalls: []response.ToolCall{ + ToolCalls: []protocol.ToolCall{ { - ID: "call_123", - Type: "function", - Function: response.ToolCallFunction{ - Name: "get_weather", - Arguments: `{"location":"Boston"}`, - }, + ID: "call_123", + Name: "get_weather", + Arguments: `{"location":"Boston"}`, }, }, }, @@ -167,9 +162,7 @@ func TestClient_Execute_Tools(t *testing.T) { } c := client.New(cfg) - messages := []protocol.Message{ - protocol.NewMessage("user", "What's the weather in Boston?"), - } + messages := protocol.InitMessages("user", "What's the weather in Boston?") tools := []protocol.Tool{ { @@ -208,8 +201,8 @@ func TestClient_Execute_Tools(t *testing.T) { } toolCall := toolsResp.Choices[0].Message.ToolCalls[0] - if toolCall.Function.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") + if toolCall.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") } } @@ -310,9 +303,7 @@ func TestClient_Execute_HTTPError(t *testing.T) { } c := client.New(cfg) - messages := []protocol.Message{ - protocol.NewMessage("user", "Hello"), - } + messages := protocol.InitMessages("user", "Hello") req := request.NewChat(provider, mdl, messages, map[string]any{}) _, err = c.Execute(context.Background(), req) diff --git a/agent/mock/agent.go b/agent/mock/agent.go index 3070e12..506aa7f 100644 --- a/agent/mock/agent.go +++ b/agent/mock/agent.go @@ -159,12 +159,12 @@ func (m *MockAgent) Model() *model.Model { } // Chat returns the predetermined chat response. -func (m *MockAgent) Chat(ctx context.Context, prompt string, opts ...map[string]any) (*response.ChatResponse, error) { +func (m *MockAgent) Chat(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (*response.ChatResponse, error) { return m.chatResponse, m.chatError } // ChatStream returns a channel with predetermined streaming chunks. -func (m *MockAgent) ChatStream(ctx context.Context, prompt string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { +func (m *MockAgent) ChatStream(ctx context.Context, prompt []protocol.Message, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { if m.streamError != nil { return nil, m.streamError } @@ -179,12 +179,12 @@ func (m *MockAgent) ChatStream(ctx context.Context, prompt string, opts ...map[s } // Vision returns the predetermined vision response. -func (m *MockAgent) Vision(ctx context.Context, prompt string, images []string, opts ...map[string]any) (*response.ChatResponse, error) { +func (m *MockAgent) Vision(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (*response.ChatResponse, error) { return m.visionResponse, m.visionError } // VisionStream returns a channel with predetermined streaming chunks. -func (m *MockAgent) VisionStream(ctx context.Context, prompt string, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { +func (m *MockAgent) VisionStream(ctx context.Context, prompt []protocol.Message, images []string, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { if m.streamError != nil { return nil, m.streamError } @@ -199,7 +199,7 @@ func (m *MockAgent) VisionStream(ctx context.Context, prompt string, images []st } // Tools returns the predetermined tools response. -func (m *MockAgent) Tools(ctx context.Context, prompt string, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { +func (m *MockAgent) Tools(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { return m.toolsResponse, m.toolsError } diff --git a/agent/mock/agent_test.go b/agent/mock/agent_test.go index c6869ff..68cee3c 100644 --- a/agent/mock/agent_test.go +++ b/agent/mock/agent_test.go @@ -45,7 +45,7 @@ func TestMockAgent_Chat(t *testing.T) { mock.WithChatResponse(expectedResponse, nil), ) - resp, err := agent.Chat(context.Background(), "test") + resp, err := agent.Chat(context.Background(), protocol.InitMessages(protocol.RoleUser, "test")) if err != nil { t.Fatalf("Chat failed: %v", err) @@ -78,7 +78,7 @@ func TestMockAgent_Vision(t *testing.T) { mock.WithVisionResponse(expectedResponse, nil), ) - resp, err := agent.Vision(context.Background(), "test", []string{"image.png"}) + resp, err := agent.Vision(context.Background(), protocol.InitMessages(protocol.RoleUser, "test"), []string{"image.png"}) if err != nil { t.Fatalf("Vision failed: %v", err) @@ -98,7 +98,7 @@ func TestMockAgent_Tools(t *testing.T) { Message struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` }{ @@ -106,18 +106,15 @@ func TestMockAgent_Tools(t *testing.T) { Message: struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Role: "assistant", Content: "", - ToolCalls: []response.ToolCall{ + ToolCalls: []protocol.ToolCall{ { - ID: "call_123", - Type: "function", - Function: response.ToolCallFunction{ - Name: "test_func", - Arguments: `{}`, - }, + ID: "call_123", + Name: "test_func", + Arguments: `{}`, }, }, }, @@ -128,7 +125,7 @@ func TestMockAgent_Tools(t *testing.T) { mock.WithToolsResponse(expectedResponse, nil), ) - resp, err := agent.Tools(context.Background(), "test", nil) + resp, err := agent.Tools(context.Background(), protocol.InitMessages(protocol.RoleUser, "test"), nil) if err != nil { t.Fatalf("Tools failed: %v", err) @@ -215,7 +212,7 @@ func TestNewAudioAgent(t *testing.T) { func TestNewSimpleChatAgent(t *testing.T) { agent := mock.NewSimpleChatAgent("test-id", "Hello, world!") - resp, err := agent.Chat(context.Background(), "test") + resp, err := agent.Chat(context.Background(), protocol.InitMessages(protocol.RoleUser, "test")) if err != nil { t.Fatalf("Chat failed: %v", err) @@ -229,7 +226,7 @@ func TestNewSimpleChatAgent(t *testing.T) { func TestNewStreamingChatAgent(t *testing.T) { agent := mock.NewStreamingChatAgent("test-id", []string{"Hello", ", ", "world!"}) - stream, err := agent.ChatStream(context.Background(), "test") + stream, err := agent.ChatStream(context.Background(), protocol.InitMessages(protocol.RoleUser, "test")) if err != nil { t.Fatalf("ChatStream failed: %v", err) diff --git a/agent/mock/helpers.go b/agent/mock/helpers.go index 4e3a470..f374bae 100644 --- a/agent/mock/helpers.go +++ b/agent/mock/helpers.go @@ -65,7 +65,7 @@ func NewStreamingChatAgent(id string, chunks []string) *MockAgent { // NewToolsAgent creates a MockAgent configured for tool calling. // Returns tool calls in the Tools response. -func NewToolsAgent(id string, toolCalls []response.ToolCall) *MockAgent { +func NewToolsAgent(id string, toolCalls []protocol.ToolCall) *MockAgent { toolsResponse := &response.ToolsResponse{ Model: "mock-model", } @@ -74,7 +74,7 @@ func NewToolsAgent(id string, toolCalls []response.ToolCall) *MockAgent { Message struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` }{ @@ -82,7 +82,7 @@ func NewToolsAgent(id string, toolCalls []response.ToolCall) *MockAgent { Message: struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Role: "assistant", Content: "", @@ -158,7 +158,7 @@ func NewMultiProtocolAgent(id string) *MockAgent { Message struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` }{ @@ -166,11 +166,11 @@ func NewMultiProtocolAgent(id string) *MockAgent { Message: struct { Role string `json:"role"` Content string `json:"content"` - ToolCalls []response.ToolCall `json:"tool_calls,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Role: "assistant", Content: "", - ToolCalls: []response.ToolCall{}, + ToolCalls: []protocol.ToolCall{}, }, }) diff --git a/agent/providers/azure_test.go b/agent/providers/azure_test.go index 17fc825..7e619b6 100644 --- a/agent/providers/azure_test.go +++ b/agent/providers/azure_test.go @@ -185,9 +185,7 @@ func TestAzure_PrepareRequest(t *testing.T) { chatData := &providers.ChatData{ Model: "gpt-4", - Messages: []protocol.Message{ - protocol.NewMessage("user", "Hello"), - }, + Messages: protocol.InitMessages("user", "Hello"), Options: map[string]any{}, } @@ -243,9 +241,7 @@ func TestAzure_PrepareStreamRequest(t *testing.T) { chatData := &providers.ChatData{ Model: "gpt-4", - Messages: []protocol.Message{ - protocol.NewMessage("user", "Hello"), - }, + Messages: protocol.InitMessages("user", "Hello"), Options: map[string]any{"stream": true}, } diff --git a/agent/providers/base_test.go b/agent/providers/base_test.go index 13fae57..1976377 100644 --- a/agent/providers/base_test.go +++ b/agent/providers/base_test.go @@ -45,9 +45,7 @@ func TestBaseProvider_Marshal_Chat(t *testing.T) { chatData := &providers.ChatData{ Model: "gpt-4", - Messages: []protocol.Message{ - protocol.NewMessage("user", "Hello"), - }, + Messages: protocol.InitMessages("user", "Hello"), Options: map[string]any{ "temperature": 0.7, }, @@ -85,9 +83,7 @@ func TestBaseProvider_Marshal_Vision(t *testing.T) { visionData := &providers.VisionData{ Model: "gpt-4-vision", - Messages: []protocol.Message{ - protocol.NewMessage("user", "What is in this image?"), - }, + Messages: protocol.InitMessages("user", "What is in this image?"), Images: []string{"https://example.com/image.jpg"}, Options: map[string]any{ "max_tokens": 1024, @@ -118,9 +114,7 @@ func TestBaseProvider_Marshal_Tools(t *testing.T) { toolsData := &providers.ToolsData{ Model: "gpt-4", - Messages: []protocol.Message{ - protocol.NewMessage("user", "What's the weather?"), - }, + Messages: protocol.InitMessages("user", "What's the weather?"), Tools: []protocol.Tool{ { Name: "get_weather", diff --git a/agent/providers/ollama_test.go b/agent/providers/ollama_test.go index 722acbd..3cd8794 100644 --- a/agent/providers/ollama_test.go +++ b/agent/providers/ollama_test.go @@ -143,9 +143,7 @@ func TestOllama_PrepareRequest(t *testing.T) { // Marshal chat data using the provider chatData := &providers.ChatData{ Model: "llama2", - Messages: []protocol.Message{ - protocol.NewMessage("user", "Hello"), - }, + Messages: protocol.InitMessages("user", "Hello"), Options: map[string]any{}, } @@ -195,9 +193,7 @@ func TestOllama_PrepareStreamRequest(t *testing.T) { chatData := &providers.ChatData{ Model: "llama2", - Messages: []protocol.Message{ - protocol.NewMessage("user", "Hello"), - }, + Messages: protocol.InitMessages("user", "Hello"), Options: map[string]any{"stream": true}, } diff --git a/cmd/prompt-agent/main.go b/cmd/prompt-agent/main.go index e588256..1a2a7f8 100644 --- a/cmd/prompt-agent/main.go +++ b/cmd/prompt-agent/main.go @@ -94,7 +94,9 @@ func main() { } func executeChat(ctx context.Context, agent agent.Agent, prompt string) { - response, err := agent.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := agent.Chat(ctx, messages) if err != nil { log.Fatalf("Chat failed: %v", err) } @@ -110,7 +112,9 @@ func executeChat(ctx context.Context, agent agent.Agent, prompt string) { } func executeChatStream(ctx context.Context, agent agent.Agent, prompt string) { - stream, err := agent.ChatStream(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + stream, err := agent.ChatStream(ctx, messages) if err != nil { log.Fatalf("ChatStream failed: %v", err) } @@ -125,7 +129,9 @@ func executeChatStream(ctx context.Context, agent agent.Agent, prompt string) { } func executeVision(ctx context.Context, agent agent.Agent, prompt string, images []string) { - response, err := agent.Vision(ctx, prompt, images) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := agent.Vision(ctx, messages, images) if err != nil { log.Fatalf("Vision failed: %v", err) } @@ -141,7 +147,9 @@ func executeVision(ctx context.Context, agent agent.Agent, prompt string, images } func executeVisionStream(ctx context.Context, agent agent.Agent, prompt string, images []string) { - stream, err := agent.VisionStream(ctx, prompt, images) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + stream, err := agent.VisionStream(ctx, messages, images) if err != nil { log.Fatalf("VisionStream failed: %v", err) } @@ -158,7 +166,9 @@ func executeVisionStream(ctx context.Context, agent agent.Agent, prompt string, } func executeTools(ctx context.Context, agent agent.Agent, prompt string, tools []protocol.Tool) { - response, err := agent.Tools(ctx, prompt, tools) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := agent.Tools(ctx, messages, tools) if err != nil { log.Fatalf("Tools failed: %v", err) } @@ -173,7 +183,7 @@ func executeTools(ctx context.Context, agent agent.Agent, prompt string, tools [ if len(message.ToolCalls) > 0 { fmt.Printf("\nTool Calls:\n") for _, toolCall := range message.ToolCalls { - fmt.Printf(" - %s(%s)\n", toolCall.Function.Name, toolCall.Function.Arguments) + fmt.Printf(" - %s(%s)\n", toolCall.Name, toolCall.Arguments) } } } diff --git a/core/protocol/message.go b/core/protocol/message.go index 898a047..99b79b9 100644 --- a/core/protocol/message.go +++ b/core/protocol/message.go @@ -1,5 +1,7 @@ package protocol +import "encoding/json" + // Role identifies the sender of a conversation message. type Role string @@ -11,14 +13,41 @@ const ( ) // ToolCall represents a tool invocation in conversation history. -// This is the canonical flat form used across the kernel. Distinct from -// response.ToolCall, which is a JSON deserialization struct for provider responses. +// Fields are flat (ID, Name, Arguments) for direct use across the kernel. +// UnmarshalJSON transparently handles the nested LLM API format +// (function.name, function.arguments) so provider responses decode correctly. type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments string `json:"arguments"` } +// UnmarshalJSON handles both the nested LLM API format ({function: {name, arguments}}) +// and the flat kernel format ({name, arguments}). This allows provider responses to +// decode directly into the canonical ToolCall type. +func (tc *ToolCall) UnmarshalJSON(data []byte) error { + var nested struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } + if err := json.Unmarshal(data, &nested); err != nil { + return err + } + + if nested.Function.Name != "" { + tc.ID = nested.ID + tc.Name = nested.Function.Name + tc.Arguments = nested.Function.Arguments + return nil + } + + type plain ToolCall + return json.Unmarshal(data, (*plain)(tc)) +} + // Message represents a single message in a conversation. // Role indicates the sender, and Content can be a string for text or a // structured object for multimodal content (e.g., vision arrays). @@ -41,3 +70,9 @@ type Message struct { func NewMessage(role Role, content any) Message { return Message{Role: role, Content: content} } + +// InitMessages creates a single-element message slice from a role and content string. +// Convenience wrapper for the common pattern of initializing a conversation from a prompt. +func InitMessages(role Role, content string) []Message { + return []Message{NewMessage(role, content)} +} diff --git a/core/protocol/protocol_test.go b/core/protocol/protocol_test.go index cc84e74..c55946d 100644 --- a/core/protocol/protocol_test.go +++ b/core/protocol/protocol_test.go @@ -238,6 +238,128 @@ func TestMessage_JSON_IncludesToolFields(t *testing.T) { } } +func TestToolCall_UnmarshalJSON_NestedFormat(t *testing.T) { + data := `{ + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"Boston\"}" + } + }` + + var tc protocol.ToolCall + if err := json.Unmarshal([]byte(data), &tc); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if tc.ID != "call_123" { + t.Errorf("got ID %q, want %q", tc.ID, "call_123") + } + if tc.Name != "get_weather" { + t.Errorf("got Name %q, want %q", tc.Name, "get_weather") + } + if tc.Arguments != `{"location":"Boston"}` { + t.Errorf("got Arguments %q, want %q", tc.Arguments, `{"location":"Boston"}`) + } +} + +func TestToolCall_UnmarshalJSON_FlatFormat(t *testing.T) { + data := `{ + "id": "call_456", + "name": "search", + "arguments": "{\"query\":\"test\"}" + }` + + var tc protocol.ToolCall + if err := json.Unmarshal([]byte(data), &tc); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if tc.ID != "call_456" { + t.Errorf("got ID %q, want %q", tc.ID, "call_456") + } + if tc.Name != "search" { + t.Errorf("got Name %q, want %q", tc.Name, "search") + } + if tc.Arguments != `{"query":"test"}` { + t.Errorf("got Arguments %q, want %q", tc.Arguments, `{"query":"test"}`) + } +} + +func TestToolCall_UnmarshalJSON_InvalidJSON(t *testing.T) { + var tc protocol.ToolCall + err := json.Unmarshal([]byte(`{invalid}`), &tc) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestToolCall_UnmarshalJSON_EmptyObject(t *testing.T) { + var tc protocol.ToolCall + if err := json.Unmarshal([]byte(`{}`), &tc); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if tc.ID != "" || tc.Name != "" || tc.Arguments != "" { + t.Errorf("expected empty ToolCall, got %+v", tc) + } +} + +func TestToolCall_UnmarshalJSON_InArray(t *testing.T) { + data := `[ + { + "id": "call_1", + "type": "function", + "function": { + "name": "fn_a", + "arguments": "{}" + } + }, + { + "id": "call_2", + "name": "fn_b", + "arguments": "{\"x\":1}" + } + ]` + + var calls []protocol.ToolCall + if err := json.Unmarshal([]byte(data), &calls); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if len(calls) != 2 { + t.Fatalf("got %d calls, want 2", len(calls)) + } + + if calls[0].Name != "fn_a" { + t.Errorf("call[0] Name = %q, want %q", calls[0].Name, "fn_a") + } + if calls[1].Name != "fn_b" { + t.Errorf("call[1] Name = %q, want %q", calls[1].Name, "fn_b") + } +} + +func TestInitMessages(t *testing.T) { + messages := protocol.InitMessages(protocol.RoleUser, "Hello") + + if len(messages) != 1 { + t.Fatalf("got %d messages, want 1", len(messages)) + } + + if messages[0].Role != protocol.RoleUser { + t.Errorf("got role %q, want %q", messages[0].Role, protocol.RoleUser) + } + + content, ok := messages[0].Content.(string) + if !ok { + t.Fatalf("content is not string: %T", messages[0].Content) + } + if content != "Hello" { + t.Errorf("got content %q, want %q", content, "Hello") + } +} + func TestNewMessage_Roles(t *testing.T) { tests := []struct { name string diff --git a/core/response/response_test.go b/core/response/response_test.go index ce1a14f..291b4a9 100644 --- a/core/response/response_test.go +++ b/core/response/response_test.go @@ -258,8 +258,8 @@ func TestToolsResponse_Unmarshal(t *testing.T) { t.Errorf("got tool call ID %q, want %q", toolCall.ID, "call_123") } - if toolCall.Function.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") + if toolCall.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") } } diff --git a/core/response/tools.go b/core/response/tools.go index b4b2109..c9664ed 100644 --- a/core/response/tools.go +++ b/core/response/tools.go @@ -3,6 +3,8 @@ package response import ( "encoding/json" "fmt" + + "github.com/tailored-agentic-units/kernel/core/protocol" ) // ToolsResponse represents the response from a tools (function calling) protocol request. @@ -15,30 +17,15 @@ type ToolsResponse struct { Choices []struct { Index int `json:"index"` Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"message"` FinishReason string `json:"finish_reason,omitempty"` } `json:"choices"` Usage *TokenUsage `json:"usage,omitempty"` } -// ToolCall represents a function call requested by the model. -// Contains the call ID, type, and function details. -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function ToolCallFunction `json:"function"` -} - -// ToolCallFunction contains the details of a function to be called. -// Name specifies the function name, and Arguments contains JSON-encoded parameters. -type ToolCallFunction struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - // ParseTools parses a tools response from JSON bytes. // Returns the parsed ToolsResponse or an error if parsing fails. func ParseTools(body []byte) (*ToolsResponse, error) { diff --git a/kernel/config.go b/kernel/config.go new file mode 100644 index 0000000..9f91b62 --- /dev/null +++ b/kernel/config.go @@ -0,0 +1,67 @@ +package kernel + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/memory" + "github.com/tailored-agentic-units/kernel/session" +) + +const defaultMaxIterations = 10 + +// Config holds initialization parameters for all kernel subsystems. +// Each subsystem section delegates to that subsystem's config-driven constructor. +type Config struct { + Agent config.AgentConfig `json:"agent"` + Session session.Config `json:"session"` + Memory memory.Config `json:"memory"` + MaxIterations int `json:"max_iterations,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` +} + +// DefaultConfig returns a Config with sensible defaults for all subsystems. +func DefaultConfig() Config { + return Config{ + Agent: config.DefaultAgentConfig(), + Session: session.DefaultConfig(), + Memory: memory.DefaultConfig(), + MaxIterations: defaultMaxIterations, + } +} + +// Merge applies non-zero values from source into c, delegating to each +// subsystem's Merge method. +func (c *Config) Merge(source *Config) { + c.Agent.Merge(&source.Agent) + c.Session.Merge(&source.Session) + c.Memory.Merge(&source.Memory) + + if source.MaxIterations > 0 { + c.MaxIterations = source.MaxIterations + } + if source.SystemPrompt != "" { + c.SystemPrompt = source.SystemPrompt + } +} + +// LoadConfig reads a JSON config file, merges it with defaults, and returns +// the resulting Config. +func LoadConfig(filename string) (*Config, error) { + cfg := DefaultConfig() + + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var loaded Config + if err := json.Unmarshal(data, &loaded); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + cfg.Merge(&loaded) + return &cfg, nil +} diff --git a/kernel/config_test.go b/kernel/config_test.go new file mode 100644 index 0000000..6f776c5 --- /dev/null +++ b/kernel/config_test.go @@ -0,0 +1,104 @@ +package kernel_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/tailored-agentic-units/kernel/kernel" +) + +func TestDefaultConfig(t *testing.T) { + cfg := kernel.DefaultConfig() + + if cfg.MaxIterations != 10 { + t.Errorf("got MaxIterations %d, want 10", cfg.MaxIterations) + } +} + +func TestConfig_Merge(t *testing.T) { + cfg := kernel.DefaultConfig() + + source := &kernel.Config{ + MaxIterations: 20, + SystemPrompt: "merged prompt", + } + + cfg.Merge(source) + + if cfg.MaxIterations != 20 { + t.Errorf("got MaxIterations %d, want 20", cfg.MaxIterations) + } + + if cfg.SystemPrompt != "merged prompt" { + t.Errorf("got SystemPrompt %q, want %q", cfg.SystemPrompt, "merged prompt") + } +} + +func TestConfig_Merge_ZeroValuesPreserveDefaults(t *testing.T) { + cfg := kernel.DefaultConfig() + original := cfg.MaxIterations + + source := &kernel.Config{} // All zero values + + cfg.Merge(source) + + if cfg.MaxIterations != original { + t.Errorf("got MaxIterations %d, want %d (preserved default)", cfg.MaxIterations, original) + } +} + +func TestLoadConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + + content := `{ + "max_iterations": 25, + "system_prompt": "loaded prompt", + "memory": { + "path": "/tmp/mem" + } + }` + + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg, err := kernel.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if cfg.MaxIterations != 25 { + t.Errorf("got MaxIterations %d, want 25", cfg.MaxIterations) + } + + if cfg.SystemPrompt != "loaded prompt" { + t.Errorf("got SystemPrompt %q, want %q", cfg.SystemPrompt, "loaded prompt") + } + + if cfg.Memory.Path != "/tmp/mem" { + t.Errorf("got Memory.Path %q, want %q", cfg.Memory.Path, "/tmp/mem") + } +} + +func TestLoadConfig_FileNotFound(t *testing.T) { + _, err := kernel.LoadConfig("/nonexistent/path/config.json") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestLoadConfig_InvalidJSON(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "bad.json") + + if err := os.WriteFile(configPath, []byte("{invalid}"), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + _, err := kernel.LoadConfig(configPath) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} diff --git a/kernel/errors.go b/kernel/errors.go new file mode 100644 index 0000000..295c3fc --- /dev/null +++ b/kernel/errors.go @@ -0,0 +1,7 @@ +package kernel + +import "errors" + +// ErrMaxIterations is returned by Run when the loop exhausts its iteration +// budget without the agent producing a final response. +var ErrMaxIterations = errors.New("max iterations reached") diff --git a/kernel/kernel.go b/kernel/kernel.go new file mode 100644 index 0000000..dcbe0be --- /dev/null +++ b/kernel/kernel.go @@ -0,0 +1,255 @@ +// Package kernel implements the single-agent runtime loop that composes +// agent, tools, session, and memory into the observe/think/act/repeat cycle. +// +// The kernel initializes from configuration via New, creating all subsystems +// internally. Functional options allow test overrides of any subsystem. +// +// k, err := kernel.New(&cfg) +// result, err := k.Run(ctx, "What's the weather in Boston?") +package kernel + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/tailored-agentic-units/kernel/agent" + "github.com/tailored-agentic-units/kernel/core/protocol" + "github.com/tailored-agentic-units/kernel/memory" + "github.com/tailored-agentic-units/kernel/session" + "github.com/tailored-agentic-units/kernel/tools" +) + +// Result holds the outcome of a kernel Run invocation. +type Result struct { + Response string // Final text response from the agent. + Iterations int // Number of loop cycles completed. + ToolCalls []ToolCallRecord // Log of all tool invocations. +} + +// ToolCallRecord captures a single tool invocation within the loop. +type ToolCallRecord struct { + Iteration int // Loop cycle in which the call occurred. + ID string // Provider-assigned call identifier. + Name string // Tool name. + Arguments string // JSON-encoded arguments. + Result string // Tool execution output. + IsError bool // Whether execution returned an error. +} + +// ToolExecutor abstracts tool listing and execution for testability. +// The default implementation delegates to the global tools package. +type ToolExecutor interface { + List() []protocol.Tool + Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) +} + +type globalToolExecutor struct{} + +func (globalToolExecutor) List() []protocol.Tool { + return tools.List() +} + +func (globalToolExecutor) Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Execute(ctx, name, args) +} + +// Option configures a Kernel after config-driven initialization. +// Applied by New after cold start — overrides replace config-created defaults. +type Option func(*Kernel) + +// WithAgent overrides the config-created agent. +func WithAgent(a agent.Agent) Option { + return func(k *Kernel) { k.agent = a } +} + +// WithSession overrides the config-created session. +func WithSession(s session.Session) Option { + return func(k *Kernel) { k.session = s } +} + +// WithToolExecutor overrides the default global tool executor. +func WithToolExecutor(e ToolExecutor) Option { + return func(k *Kernel) { k.tools = e } +} + +// WithMemoryStore overrides the config-created memory store. +func WithMemoryStore(s memory.Store) Option { + return func(k *Kernel) { k.store = s } +} + +// Kernel is the single-agent runtime that executes the agentic loop. +type Kernel struct { + agent agent.Agent + session session.Session + store memory.Store + tools ToolExecutor + maxIterations int + systemPrompt string +} + +// New creates a Kernel from configuration. Subsystems (agent, session, memory) +// are initialized from their respective config sections. Functional options +// applied after initialization can override any subsystem for testing. +func New(cfg *Config, opts ...Option) (*Kernel, error) { + a, err := agent.New(&cfg.Agent) + if err != nil { + return nil, fmt.Errorf("failed to create agent: %w", err) + } + + sesh, err := session.New(&cfg.Session) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + store, err := memory.NewStore(&cfg.Memory) + if err != nil { + return nil, fmt.Errorf("failed to create memory store: %w", err) + } + + k := &Kernel{ + agent: a, + session: sesh, + store: store, + tools: globalToolExecutor{}, + maxIterations: cfg.MaxIterations, + systemPrompt: cfg.SystemPrompt, + } + + for _, opt := range opts { + opt(k) + } + + return k, nil +} + +// Run executes the observe/think/act/repeat agentic loop for the given prompt. +// Returns a Result with the final response, iteration count, and tool call log. +// Returns ErrMaxIterations if the loop exhausts its iteration budget. +func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { + k.session.AddMessage( + protocol.NewMessage(protocol.RoleUser, prompt), + ) + + result := &Result{} + + systemContent, err := k.buildSystemContent(ctx) + if err != nil { + return result, err + } + + for iteration := range k.maxIterations { + if err := ctx.Err(); err != nil { + return result, err + } + + messages := k.buildMessages(systemContent) + + resp, err := k.agent.Tools(ctx, messages, k.tools.List()) + if err != nil { + return result, fmt.Errorf("agent call failed: %w", err) + } + + if len(resp.Choices) == 0 { + return result, fmt.Errorf("agent returned empty response") + } + + choice := resp.Choices[0] + + if len(choice.Message.ToolCalls) == 0 { + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleAssistant, + Content: choice.Message.Content, + }) + result.Response = choice.Message.Content + result.Iterations = iteration + 1 + return result, nil + } + + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleAssistant, + Content: choice.Message.Content, + ToolCalls: choice.Message.ToolCalls, + }) + + for _, tc := range choice.Message.ToolCalls { + record := ToolCallRecord{ + Iteration: iteration + 1, + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } + + toolResult, toolErr := k.tools.Execute( + ctx, + tc.Name, + json.RawMessage(tc.Arguments), + ) + + if toolErr != nil { + errContent := fmt.Sprintf("error: %s", toolErr) + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleTool, + Content: errContent, + ToolCallID: tc.ID, + }) + record.Result = errContent + record.IsError = true + } else { + k.session.AddMessage(protocol.Message{ + Role: protocol.RoleTool, + Content: toolResult.Content, + ToolCallID: tc.ID, + }) + record.Result = toolResult.Content + record.IsError = toolResult.IsError + } + + result.ToolCalls = append(result.ToolCalls, record) + } + + result.Iterations = iteration + 1 + } + + return result, ErrMaxIterations +} + +func (k *Kernel) buildMessages(systemContent string) []protocol.Message { + sessionMsgs := k.session.Messages() + + if systemContent == "" { + return sessionMsgs + } + + messages := make([]protocol.Message, 0, len(sessionMsgs)+1) + messages = append(messages, protocol.NewMessage(protocol.RoleSystem, systemContent)) + messages = append(messages, sessionMsgs...) + return messages +} + +func (k *Kernel) buildSystemContent(ctx context.Context) (string, error) { + content := k.systemPrompt + + if k.store == nil { + return content, nil + } + + keys, err := k.store.List(ctx) + if err != nil { + return "", fmt.Errorf("failed to list memory keys: %w", err) + } + if len(keys) == 0 { + return content, nil + } + + entries, err := k.store.Load(ctx, keys...) + if err != nil { + return "", fmt.Errorf("failed to load memory entries: %w", err) + } + + for _, entry := range entries { + content += "\n\n" + string(entry.Value) + } + + return content, nil +} diff --git a/kernel/kernel_test.go b/kernel/kernel_test.go new file mode 100644 index 0000000..85cac97 --- /dev/null +++ b/kernel/kernel_test.go @@ -0,0 +1,714 @@ +package kernel_test + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + + "github.com/tailored-agentic-units/kernel/agent/mock" + "github.com/tailored-agentic-units/kernel/core/protocol" + "github.com/tailored-agentic-units/kernel/core/response" + "github.com/tailored-agentic-units/kernel/kernel" + "github.com/tailored-agentic-units/kernel/memory" + "github.com/tailored-agentic-units/kernel/tools" +) + +// --- Test helpers --- + +// sequentialAgent returns different responses on successive Tools calls. +type sequentialAgent struct { + *mock.MockAgent + responses []*response.ToolsResponse + errors []error + callCount atomic.Int32 +} + +func newSequentialAgent(responses []*response.ToolsResponse, errs []error) *sequentialAgent { + return &sequentialAgent{ + MockAgent: mock.NewMockAgent(mock.WithID("sequential-agent")), + responses: responses, + errors: errs, + } +} + +func (a *sequentialAgent) Tools(ctx context.Context, prompt []protocol.Message, t []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { + i := int(a.callCount.Add(1)) - 1 + if i < len(a.responses) { + var err error + if i < len(a.errors) { + err = a.errors[i] + } + return a.responses[i], err + } + return nil, errors.New("no more responses configured") +} + +// mockToolExecutor implements kernel.ToolExecutor for testing. +type mockToolExecutor struct { + tools []protocol.Tool + handler func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) +} + +func (e *mockToolExecutor) List() []protocol.Tool { + return e.tools +} + +func (e *mockToolExecutor) Execute(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return e.handler(ctx, name, args) +} + +// mockMemoryStore implements memory.Store for testing. +type mockMemoryStore struct { + keys []string + entries []memory.Entry + listErr error + loadErr error +} + +func (s *mockMemoryStore) List(ctx context.Context) ([]string, error) { + return s.keys, s.listErr +} + +func (s *mockMemoryStore) Load(ctx context.Context, keys ...string) ([]memory.Entry, error) { + return s.entries, s.loadErr +} + +func (s *mockMemoryStore) Save(ctx context.Context, entries ...memory.Entry) error { + return nil +} + +func (s *mockMemoryStore) Delete(ctx context.Context, keys ...string) error { + return nil +} + +// makeToolsResponse builds a ToolsResponse with tool calls. +func makeToolsResponse(toolCalls []protocol.ToolCall) *response.ToolsResponse { + resp := &response.ToolsResponse{Model: "mock"} + resp.Choices = append(resp.Choices, struct { + Index int `json:"index"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + } `json:"message"` + FinishReason string `json:"finish_reason,omitempty"` + }{ + Index: 0, + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + }{ + Role: "assistant", + ToolCalls: toolCalls, + }, + }) + return resp +} + +// makeFinalResponse builds a ToolsResponse with text content (no tool calls). +func makeFinalResponse(content string) *response.ToolsResponse { + resp := &response.ToolsResponse{Model: "mock"} + resp.Choices = append(resp.Choices, struct { + Index int `json:"index"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + } `json:"message"` + FinishReason string `json:"finish_reason,omitempty"` + }{ + Index: 0, + Message: struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + }{ + Role: "assistant", + Content: content, + }, + }) + return resp +} + +// minimalConfig returns a Config suitable for tests using functional options. +// Uses DefaultConfig so the cold start (agent, session, memory creation) succeeds +// before options override subsystems with test mocks. +func minimalConfig() *kernel.Config { + cfg := kernel.DefaultConfig() + return &cfg +} + +// --- Tests --- + +func TestRun_DirectResponse(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("Hello!")}, + nil, + ) + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "Hi") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if result.Response != "Hello!" { + t.Errorf("got response %q, want %q", result.Response, "Hello!") + } + + if result.Iterations != 1 { + t.Errorf("got %d iterations, want 1", result.Iterations) + } + + if len(result.ToolCalls) != 0 { + t.Errorf("got %d tool calls, want 0", len(result.ToolCalls)) + } +} + +func TestRun_SingleToolCall(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_1", Name: "greet", Arguments: `{"name":"world"}`}, + }), + makeFinalResponse("Done: hello world"), + }, + nil, + ) + + executor := &mockToolExecutor{ + tools: []protocol.Tool{{Name: "greet", Description: "Greet someone"}}, + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Result{Content: "hello world"}, nil + }, + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "Greet the world") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if result.Response != "Done: hello world" { + t.Errorf("got response %q, want %q", result.Response, "Done: hello world") + } + + if result.Iterations != 2 { + t.Errorf("got %d iterations, want 2", result.Iterations) + } + + if len(result.ToolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(result.ToolCalls)) + } + + tc := result.ToolCalls[0] + if tc.Name != "greet" { + t.Errorf("got tool name %q, want %q", tc.Name, "greet") + } + if tc.Result != "hello world" { + t.Errorf("got tool result %q, want %q", tc.Result, "hello world") + } + if tc.IsError { + t.Error("tool call marked as error, want success") + } +} + +func TestRun_MultipleToolCalls(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_1", Name: "add", Arguments: `{"a":1,"b":2}`}, + {ID: "call_2", Name: "add", Arguments: `{"a":3,"b":4}`}, + }), + makeFinalResponse("3 and 7"), + }, + nil, + ) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + var params struct{ A, B int } + json.Unmarshal(args, ¶ms) + return tools.Result{Content: json.Number(json.Number(string(rune('0' + params.A + params.B)))).String()}, nil + }, + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "Add these") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if len(result.ToolCalls) != 2 { + t.Fatalf("got %d tool calls, want 2", len(result.ToolCalls)) + } +} + +func TestRun_ToolExecutionError(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_1", Name: "fail", Arguments: `{}`}, + }), + makeFinalResponse("I handled the error"), + }, + nil, + ) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Result{}, errors.New("tool broke") + }, + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "Try the failing tool") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if result.Response != "I handled the error" { + t.Errorf("got response %q, want %q", result.Response, "I handled the error") + } + + if len(result.ToolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(result.ToolCalls)) + } + + tc := result.ToolCalls[0] + if !tc.IsError { + t.Error("tool call not marked as error") + } + if tc.Result != "error: tool broke" { + t.Errorf("got error result %q, want %q", tc.Result, "error: tool broke") + } +} + +func TestRun_MaxIterations(t *testing.T) { + // Agent always returns tool calls, never a final response + infiniteToolCall := makeToolsResponse([]protocol.ToolCall{ + {ID: "call_loop", Name: "loop", Arguments: `{}`}, + }) + + responses := make([]*response.ToolsResponse, 5) + for i := range responses { + responses[i] = infiniteToolCall + } + + agent := newSequentialAgent(responses, nil) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Result{Content: "looping"}, nil + }, + } + + cfg := minimalConfig() + cfg.MaxIterations = 3 + + k, err := kernel.New(cfg, + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "Loop forever") + if !errors.Is(err, kernel.ErrMaxIterations) { + t.Fatalf("got error %v, want ErrMaxIterations", err) + } + + if result.Iterations != 3 { + t.Errorf("got %d iterations, want 3", result.Iterations) + } + + if len(result.ToolCalls) != 3 { + t.Errorf("got %d tool calls, want 3", len(result.ToolCalls)) + } +} + +func TestRun_ContextCancellation(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_1", Name: "slow", Arguments: `{}`}, + }), + }, + nil, + ) + + ctx, cancel := context.WithCancel(context.Background()) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + cancel() // Cancel after first tool execution + return tools.Result{Content: "done"}, nil + }, + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(ctx, "Do something") + if !errors.Is(err, context.Canceled) { + t.Errorf("got error %v, want context.Canceled", err) + } +} + +func TestRun_AgentError(t *testing.T) { + agent := newSequentialAgent(nil, []error{errors.New("agent exploded")}) + agent.responses = []*response.ToolsResponse{nil} + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Boom") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, errors.New("")) && err.Error() != "agent call failed: agent exploded" { + // Just check it wraps the agent error + if err.Error() != "agent call failed: agent exploded" { + t.Errorf("got error %q, want wrapped agent error", err) + } + } +} + +func TestRun_EmptyResponse(t *testing.T) { + // Response with no choices + emptyResp := &response.ToolsResponse{Model: "mock"} + + agent := newSequentialAgent([]*response.ToolsResponse{emptyResp}, nil) + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err == nil { + t.Fatal("expected error for empty response, got nil") + } +} + +func TestRun_SystemPrompt(t *testing.T) { + var capturedMessages []protocol.Message + + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + // Wrap to capture messages + wrapper := &messageCapturingAgent{ + sequentialAgent: agent, + captured: &capturedMessages, + } + + cfg := minimalConfig() + cfg.SystemPrompt = "You are a test assistant." + + k, err := kernel.New(cfg, + kernel.WithAgent(wrapper), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if len(capturedMessages) < 2 { + t.Fatalf("expected at least 2 messages (system + user), got %d", len(capturedMessages)) + } + + if capturedMessages[0].Role != protocol.RoleSystem { + t.Errorf("first message role = %q, want %q", capturedMessages[0].Role, protocol.RoleSystem) + } + if capturedMessages[0].Content != "You are a test assistant." { + t.Errorf("system content = %q, want %q", capturedMessages[0].Content, "You are a test assistant.") + } +} + +func TestRun_MemoryInjection(t *testing.T) { + var capturedMessages []protocol.Message + + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + wrapper := &messageCapturingAgent{ + sequentialAgent: agent, + captured: &capturedMessages, + } + + store := &mockMemoryStore{ + keys: []string{"key1"}, + entries: []memory.Entry{ + {Key: "key1", Value: []byte("remembered context")}, + }, + } + + cfg := minimalConfig() + cfg.SystemPrompt = "Base prompt." + + k, err := kernel.New(cfg, + kernel.WithAgent(wrapper), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + kernel.WithMemoryStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if len(capturedMessages) == 0 { + t.Fatal("no messages captured") + } + + systemContent, ok := capturedMessages[0].Content.(string) + if !ok { + t.Fatalf("system content is not string: %T", capturedMessages[0].Content) + } + + if systemContent != "Base prompt.\n\nremembered context" { + t.Errorf("got system content %q, want %q", systemContent, "Base prompt.\n\nremembered context") + } +} + +func TestRun_MemoryListError(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + + store := &mockMemoryStore{ + listErr: errors.New("disk failure"), + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + kernel.WithMemoryStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err == nil { + t.Fatal("expected error from memory list, got nil") + } +} + +func TestRun_MemoryLoadError(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + + store := &mockMemoryStore{ + keys: []string{"key1"}, + loadErr: errors.New("corrupt data"), + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + kernel.WithMemoryStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err == nil { + t.Fatal("expected error from memory load, got nil") + } +} + +func TestRun_NoMemoryStore(t *testing.T) { + var capturedMessages []protocol.Message + + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + wrapper := &messageCapturingAgent{ + sequentialAgent: agent, + captured: &capturedMessages, + } + + cfg := minimalConfig() + cfg.SystemPrompt = "Just the prompt." + + k, err := kernel.New(cfg, + kernel.WithAgent(wrapper), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + systemContent, ok := capturedMessages[0].Content.(string) + if !ok { + t.Fatalf("system content is not string: %T", capturedMessages[0].Content) + } + + if systemContent != "Just the prompt." { + t.Errorf("got %q, want %q", systemContent, "Just the prompt.") + } +} + +func TestRun_ToolCallRecordFields(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_abc", Name: "mytool", Arguments: `{"x":1}`}, + }), + makeFinalResponse("done"), + }, + nil, + ) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Result{Content: "result_value"}, nil + }, + } + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(executor), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + result, err := k.Run(context.Background(), "test") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if len(result.ToolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(result.ToolCalls)) + } + + tc := result.ToolCalls[0] + if tc.Iteration != 1 { + t.Errorf("got iteration %d, want 1", tc.Iteration) + } + if tc.ID != "call_abc" { + t.Errorf("got ID %q, want %q", tc.ID, "call_abc") + } + if tc.Name != "mytool" { + t.Errorf("got name %q, want %q", tc.Name, "mytool") + } + if tc.Arguments != `{"x":1}` { + t.Errorf("got arguments %q, want %q", tc.Arguments, `{"x":1}`) + } + if tc.Result != "result_value" { + t.Errorf("got result %q, want %q", tc.Result, "result_value") + } + if tc.IsError { + t.Error("expected IsError false") + } +} + +// --- Helper types --- + +// messageCapturingAgent wraps sequentialAgent to capture the messages passed to Tools. +type messageCapturingAgent struct { + *sequentialAgent + captured *[]protocol.Message +} + +func (a *messageCapturingAgent) Tools(ctx context.Context, prompt []protocol.Message, t []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) { + *a.captured = make([]protocol.Message, len(prompt)) + copy(*a.captured, prompt) + return a.sequentialAgent.Tools(ctx, prompt, t, opts...) +} + +func newTestSession() *testSession { + return &testSession{} +} + +// testSession is a minimal Session implementation for kernel tests. +type testSession struct { + messages []protocol.Message +} + +func (s *testSession) ID() string { return "test-session" } +func (s *testSession) AddMessage(msg protocol.Message) { s.messages = append(s.messages, msg) } +func (s *testSession) Messages() []protocol.Message { return append([]protocol.Message{}, s.messages...) } +func (s *testSession) Clear() { s.messages = nil } diff --git a/memory/config.go b/memory/config.go new file mode 100644 index 0000000..37af831 --- /dev/null +++ b/memory/config.go @@ -0,0 +1,27 @@ +package memory + +// Config holds memory store initialization parameters. +type Config struct { + Path string `json:"path,omitempty"` // FileStore root directory; empty disables memory. +} + +// DefaultConfig returns the default memory configuration (disabled). +func DefaultConfig() Config { + return Config{} +} + +// Merge applies non-zero values from source into c. +func (c *Config) Merge(source *Config) { + if source.Path != "" { + c.Path = source.Path + } +} + +// NewStore creates a Store from configuration. Returns nil Store when Path +// is empty, indicating memory is disabled. +func NewStore(cfg *Config) (Store, error) { + if cfg.Path == "" { + return nil, nil + } + return NewFileStore(cfg.Path), nil +} diff --git a/memory/config_test.go b/memory/config_test.go new file mode 100644 index 0000000..956b044 --- /dev/null +++ b/memory/config_test.go @@ -0,0 +1,65 @@ +package memory_test + +import ( + "testing" + + "github.com/tailored-agentic-units/kernel/memory" +) + +func TestDefaultConfig(t *testing.T) { + cfg := memory.DefaultConfig() + + if cfg.Path != "" { + t.Errorf("got Path %q, want empty string", cfg.Path) + } +} + +func TestConfig_Merge(t *testing.T) { + cfg := memory.DefaultConfig() + + source := &memory.Config{Path: "/data/memory"} + cfg.Merge(source) + + if cfg.Path != "/data/memory" { + t.Errorf("got Path %q, want %q", cfg.Path, "/data/memory") + } +} + +func TestConfig_Merge_EmptyPreservesDefault(t *testing.T) { + cfg := memory.Config{Path: "/original"} + + source := &memory.Config{} // Empty path + cfg.Merge(source) + + if cfg.Path != "/original" { + t.Errorf("got Path %q, want %q (preserved)", cfg.Path, "/original") + } +} + +func TestNewStore_EmptyPath(t *testing.T) { + cfg := &memory.Config{} + + store, err := memory.NewStore(cfg) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + + if store != nil { + t.Error("expected nil store for empty path") + } +} + +func TestNewStore_WithPath(t *testing.T) { + dir := t.TempDir() + + cfg := &memory.Config{Path: dir} + + store, err := memory.NewStore(cfg) + if err != nil { + t.Fatalf("NewStore failed: %v", err) + } + + if store == nil { + t.Fatal("expected non-nil store for valid path") + } +} diff --git a/orchestrate/examples/darpa-procurement/workflow.go b/orchestrate/examples/darpa-procurement/workflow.go index 09e021d..c09b641 100644 --- a/orchestrate/examples/darpa-procurement/workflow.go +++ b/orchestrate/examples/darpa-procurement/workflow.go @@ -4,13 +4,13 @@ import ( "context" "fmt" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/core/response" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/state" "github.com/tailored-agentic-units/kernel/orchestrate/workflows" ) - func BuildWorkflow(wc *WorkflowConfig, registry *AgentRegistry) (state.StateGraph, error) { graphConfig := config.GraphConfig{ Name: "darpa-procurement", @@ -124,7 +124,9 @@ Provide your response in your directed JSON format.`, project.Description, project.ComponentCount()) - response, err := registry.ResearchDirector.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := registry.ResearchDirector.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("research director failed: %w", err) } @@ -179,7 +181,9 @@ Provide your response in your directed JSON format.`, request.Components, request.Justification) - response, err := registry.CostAnalyst.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := registry.CostAnalyst.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("cost analyst failed: %w", err) } @@ -238,7 +242,9 @@ Provide your response in your directed JSON format.`, request.TechnicalReqs, request.Components) - response, err := registry.ProcurementSpecialist.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := registry.ProcurementSpecialist.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("procurement specialist failed: %w", err) } @@ -306,8 +312,8 @@ Provide your response in your directed JSON format.`, } type AnalysisResult struct { - Name string - Validation *BudgetValidation + Name string + Validation *BudgetValidation Optimization *CostOptimization } @@ -317,7 +323,10 @@ Provide your response in your directed JSON format.`, switch task.Name { case "budget": - resp, err = registry.BudgetAnalyst.Chat(ctx, task.Prompt) + + messages := protocol.InitMessages(protocol.RoleUser, task.Prompt) + + resp, err = registry.BudgetAnalyst.Chat(ctx, messages) if err != nil { return AnalysisResult{}, fmt.Errorf("budget analyst failed: %w", err) } @@ -328,7 +337,9 @@ Provide your response in your directed JSON format.`, return AnalysisResult{Name: "budget", Validation: &validation}, nil case "optimizer": - resp, err = registry.CostOptimizer.Chat(ctx, task.Prompt) + messages := protocol.InitMessages(protocol.RoleUser, task.Prompt) + + resp, err = registry.CostOptimizer.Chat(ctx, messages) if err != nil { return AnalysisResult{}, fmt.Errorf("cost optimizer failed: %w", err) } @@ -478,7 +489,10 @@ Provide your response in your directed JSON format.`, processor := func(ctx context.Context, task LegalTask) (LegalReview, error) { reviewer := registry.LegalReviewers[task.ReviewerIndex] - response, err := reviewer.Chat(ctx, legalPrompt) + + messages := protocol.InitMessages(protocol.RoleUser, legalPrompt) + + response, err := reviewer.Chat(ctx, messages) if err != nil { return LegalReview{}, fmt.Errorf("legal reviewer %d failed: %w", task.ReviewerIndex+1, err) } @@ -537,7 +551,9 @@ Provide your response in your directed JSON format.`, cost, request.ProjectSummary) - response, err := registry.SecurityOfficer.Chat(ctx, securityPrompt) + messages := protocol.InitMessages(protocol.RoleUser, securityPrompt) + + response, err := registry.SecurityOfficer.Chat(ctx, messages) if err != nil { return newState, fmt.Errorf("security officer failed: %w", err) } @@ -564,7 +580,7 @@ Provide your response in your directed JSON format.`, } func routeToExecutive(ctx context.Context, s state.State, executive interface { - Chat(context.Context, string, ...map[string]any) (*response.ChatResponse, error) + Chat(context.Context, []protocol.Message, ...map[string]any) (*response.ChatResponse, error) }, title string, cost int, route string) (state.State, error) { fmt.Printf("→ Routing to %s for final approval (route: %s)...\n", title, route) @@ -595,7 +611,9 @@ Provide justification for your decision.`, request.ProjectSummary, request.Justification) - response, err := executive.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := executive.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("%s approval failed: %w", title, err) } diff --git a/orchestrate/examples/phase-01-hubs/main.go b/orchestrate/examples/phase-01-hubs/main.go index ca20a91..d8a8179 100644 --- a/orchestrate/examples/phase-01-hubs/main.go +++ b/orchestrate/examples/phase-01-hubs/main.go @@ -8,11 +8,12 @@ import ( "os" "time" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/hub" "github.com/tailored-agentic-units/kernel/orchestrate/messaging" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) func main() { @@ -151,7 +152,10 @@ Respond concisely in 1-2 sentences as flight engineer.`, // EVA Specialist 1 handler evaSpec1Handler := func(ctx context.Context, msg *messaging.Message, msgCtx *hub.MessageContext) (*messaging.Message, error) { prompt := fmt.Sprintf("%v", msg.Data) - response, err := evaSpec1.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := evaSpec1.Chat(ctx, messages) if err != nil { return nil, err } @@ -165,7 +169,10 @@ Respond concisely in 1-2 sentences as flight engineer.`, // EVA Specialist 2 handler evaSpec2Handler := func(ctx context.Context, msg *messaging.Message, msgCtx *hub.MessageContext) (*messaging.Message, error) { prompt := fmt.Sprintf("%v", msg.Data) - response, err := evaSpec2.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := evaSpec2.Chat(ctx, messages) if err != nil { return nil, err } @@ -179,7 +186,10 @@ Respond concisely in 1-2 sentences as flight engineer.`, // Mission Commander handler commanderHandler := func(ctx context.Context, msg *messaging.Message, msgCtx *hub.MessageContext) (*messaging.Message, error) { prompt := fmt.Sprintf("In %s: %v", msgCtx.HubName, msg.Data) - response, err := commander.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := commander.Chat(ctx, messages) if err != nil { return nil, err } @@ -193,7 +203,10 @@ Respond concisely in 1-2 sentences as flight engineer.`, // Flight Engineer handler flightEngHandler := func(ctx context.Context, msg *messaging.Message, msgCtx *hub.MessageContext) (*messaging.Message, error) { prompt := fmt.Sprintf("%v", msg.Data) - response, err := flightEng.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := flightEng.Chat(ctx, messages) if err != nil { return nil, err } diff --git a/orchestrate/examples/phase-02-03-state-graphs/main.go b/orchestrate/examples/phase-02-03-state-graphs/main.go index cd30059..7f5acb4 100644 --- a/orchestrate/examples/phase-02-03-state-graphs/main.go +++ b/orchestrate/examples/phase-02-03-state-graphs/main.go @@ -8,11 +8,12 @@ import ( "os" "time" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/observability" "github.com/tailored-agentic-units/kernel/orchestrate/state" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) func main() { @@ -89,7 +90,10 @@ Always respond in 1-2 sentences with specific technical information.` targetEnv, _ := s.Get("target_env") prompt := fmt.Sprintf("Analyze deployment plan for application '%s' to '%s' environment. What are the key considerations?", appName, targetEnv) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("plan failed: %w", err) } @@ -106,7 +110,10 @@ Always respond in 1-2 sentences with specific technical information.` appName, _ := s.Get("app_name") prompt := fmt.Sprintf("What artifacts should be built for '%s' application deployment? List 2-3 key artifacts.", appName) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("build failed: %w", err) } @@ -128,7 +135,10 @@ Always respond in 1-2 sentences with specific technical information.` attempts := retryCount.(int) prompt := fmt.Sprintf("Evaluate test results for deployment (attempt %d). Should tests pass (yes) or need fixes (no)?", attempts+1) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("test execution failed: %w", err) } @@ -151,7 +161,10 @@ Always respond in 1-2 sentences with specific technical information.` testResult, _ := s.Get("test_result") prompt := fmt.Sprintf("Test failed: %s. What fix should be applied (attempt %d)?", testResult, attempts) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("fix failed: %w", err) } @@ -169,7 +182,10 @@ Always respond in 1-2 sentences with specific technical information.` artifacts, _ := s.Get("artifacts") prompt := fmt.Sprintf("Confirm deployment to '%s' with artifacts: %s. Provide deployment confirmation.", targetEnv, artifacts) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("deployment failed: %w", err) } @@ -186,7 +202,10 @@ Always respond in 1-2 sentences with specific technical information.` retryCount, _ := s.Get("retry_count") prompt := fmt.Sprintf("Deployment failed after %d attempts. Describe rollback procedure.", retryCount) - response, err := deploymentAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := deploymentAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("rollback failed: %w", err) } diff --git a/orchestrate/examples/phase-04-sequential-chains/main.go b/orchestrate/examples/phase-04-sequential-chains/main.go index 0c288eb..c522272 100644 --- a/orchestrate/examples/phase-04-sequential-chains/main.go +++ b/orchestrate/examples/phase-04-sequential-chains/main.go @@ -8,12 +8,13 @@ import ( "os" "time" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/observability" "github.com/tailored-agentic-units/kernel/orchestrate/state" "github.com/tailored-agentic-units/kernel/orchestrate/workflows" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) type PaperSection struct { @@ -161,7 +162,9 @@ conditions. The protocol is ready for testnet deployment.`, return s, fmt.Errorf("unknown section: %s", sectionName) } - response, err := analysisAgent.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := analysisAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("analysis failed for %s: %w", sectionName, err) } @@ -279,7 +282,7 @@ conditions. The protocol is ready for testnet deployment.`, fmt.Printf(" Duration: %v\n", duration.Round(time.Millisecond)) fmt.Printf(" Steps Completed: %d/%d\n", result.Steps, totalSteps) fmt.Printf(" Intermediate States Captured: %d\n", len(result.Intermediate)) - fmt.Printf(" Average Time per Step: %v\n", (duration/time.Duration(result.Steps)).Round(time.Millisecond)) + fmt.Printf(" Average Time per Step: %v\n", (duration / time.Duration(result.Steps)).Round(time.Millisecond)) fmt.Println() fmt.Println("=== Research Paper Analysis Complete ===") diff --git a/orchestrate/examples/phase-05-parallel-execution/main.go b/orchestrate/examples/phase-05-parallel-execution/main.go index 7a1da7e..533d56a 100644 --- a/orchestrate/examples/phase-05-parallel-execution/main.go +++ b/orchestrate/examples/phase-05-parallel-execution/main.go @@ -8,11 +8,12 @@ import ( "os" "time" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/observability" "github.com/tailored-agentic-units/kernel/orchestrate/workflows" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) type ProductReview struct { @@ -22,11 +23,11 @@ type ProductReview struct { } type SentimentResult struct { - ReviewID int - Product string - Review string - Sentiment string - Analysis string + ReviewID int + Product string + Review string + Sentiment string + Analysis string ProcessedAt time.Time } @@ -121,7 +122,9 @@ Respond in format: "SENTIMENT" where SENTIMENT is one word: positive, neutral, o taskProcessor := func(ctx context.Context, review ProductReview) (SentimentResult, error) { prompt := fmt.Sprintf("Analyze sentiment of this review: \"%s\"", review.Review) - response, err := sentimentAgent.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := sentimentAgent.Chat(ctx, messages) if err != nil { return SentimentResult{}, fmt.Errorf("sentiment analysis failed: %w", err) } @@ -134,11 +137,11 @@ Respond in format: "SENTIMENT" where SENTIMENT is one word: positive, neutral, o } return SentimentResult{ - ReviewID: review.ID, - Product: review.Product, - Review: review.Review, - Sentiment: sentimentWord, - Analysis: analysis, + ReviewID: review.ID, + Product: review.Product, + Review: review.Review, + Sentiment: sentimentWord, + Analysis: analysis, ProcessedAt: time.Now(), }, nil } diff --git a/orchestrate/examples/phase-06-checkpointing/main.go b/orchestrate/examples/phase-06-checkpointing/main.go index c2e5748..798eed5 100644 --- a/orchestrate/examples/phase-06-checkpointing/main.go +++ b/orchestrate/examples/phase-06-checkpointing/main.go @@ -8,11 +8,12 @@ import ( "os" "time" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/observability" "github.com/tailored-agentic-units/kernel/orchestrate/state" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) var ( @@ -86,7 +87,10 @@ Keep responses to 1-2 sentences focusing on key findings or actions.` datasetName, _ := s.Get("dataset") prompt := fmt.Sprintf("Describe the key characteristics of the '%s' dataset being ingested.", datasetName) - response, err := dataAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := dataAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("ingestion failed: %w", err) } @@ -105,7 +109,10 @@ Keep responses to 1-2 sentences focusing on key findings or actions.` characteristics, _ := s.Get("characteristics") prompt := fmt.Sprintf("What preprocessing steps are needed for data with these characteristics: %s", characteristics) - response, err := dataAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := dataAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("preprocessing failed: %w", err) } @@ -133,7 +140,10 @@ Keep responses to 1-2 sentences focusing on key findings or actions.` datasetName, _ := s.Get("dataset") prompt := fmt.Sprintf("What statistical insights can be derived from analyzing the '%s' dataset?", datasetName) - response, err := dataAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := dataAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("analysis failed: %w", err) } @@ -152,7 +162,10 @@ Keep responses to 1-2 sentences focusing on key findings or actions.` insights, _ := s.Get("insights") prompt := fmt.Sprintf("Summarize these key findings in a report conclusion: %s", insights) - response, err := dataAgent.Chat(ctx, prompt) + + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := dataAgent.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("report generation failed: %w", err) } diff --git a/orchestrate/examples/phase-07-conditional-routing/main.go b/orchestrate/examples/phase-07-conditional-routing/main.go index a1ceb2b..d21adbf 100644 --- a/orchestrate/examples/phase-07-conditional-routing/main.go +++ b/orchestrate/examples/phase-07-conditional-routing/main.go @@ -8,11 +8,12 @@ import ( "os" "strings" + "github.com/tailored-agentic-units/kernel/agent" + agentconfig "github.com/tailored-agentic-units/kernel/core/config" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/orchestrate/config" "github.com/tailored-agentic-units/kernel/orchestrate/state" "github.com/tailored-agentic-units/kernel/orchestrate/workflows" - "github.com/tailored-agentic-units/kernel/agent" - agentconfig "github.com/tailored-agentic-units/kernel/core/config" ) type Document struct { @@ -38,8 +39,8 @@ type Review struct { } type Decision struct { - Approved bool - Reason string + Approved bool + Reason string RecommendedChange string } @@ -215,7 +216,9 @@ Start response with "APPROVE:" or "REJECT:" followed by reasoning.`, prompt := fmt.Sprintf("Analyze this document:\n\nTitle: %s\n\nContent: %s\n\nProvide your %s analysis.", currentDoc.Title, currentDoc.Content, strings.ToLower(item.atype)) - response, err := item.analyst.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := item.analyst.Chat(ctx, messages) if err != nil { return s, fmt.Errorf("analysis failed: %w", err) } @@ -269,7 +272,9 @@ Start response with "APPROVE:" or "REJECT:" followed by reasoning.`, prompt := "Review this document for approval. Consider prior analyses and provide clear APPROVE or REJECT decision with reasoning." - response, err := item.reviewer.Chat(ctx, prompt) + messages := protocol.InitMessages(protocol.RoleUser, prompt) + + response, err := item.reviewer.Chat(ctx, messages) if err != nil { return Review{}, fmt.Errorf("review failed: %w", err) } diff --git a/session/config.go b/session/config.go new file mode 100644 index 0000000..3170fcd --- /dev/null +++ b/session/config.go @@ -0,0 +1,18 @@ +package session + +// Config holds session initialization parameters. Currently empty — serves as +// an extension point for future session backends. +type Config struct{} + +// DefaultConfig returns the default session configuration. +func DefaultConfig() Config { + return Config{} +} + +// Merge applies non-zero values from source into c. +func (c *Config) Merge(source *Config) {} + +// New creates a Session from configuration. Currently returns an in-memory session. +func New(cfg *Config) (Session, error) { + return NewMemorySession(), nil +} diff --git a/session/config_test.go b/session/config_test.go new file mode 100644 index 0000000..c8c9bd0 --- /dev/null +++ b/session/config_test.go @@ -0,0 +1,39 @@ +package session_test + +import ( + "testing" + + "github.com/tailored-agentic-units/kernel/session" +) + +func TestDefaultConfig(t *testing.T) { + cfg := session.DefaultConfig() + + // Currently an empty struct; verify it doesn't panic. + _ = cfg +} + +func TestConfig_Merge(t *testing.T) { + cfg := session.DefaultConfig() + source := session.DefaultConfig() + + // Merge should not panic on empty configs. + cfg.Merge(&source) +} + +func TestNew_FromConfig(t *testing.T) { + cfg := session.DefaultConfig() + + s, err := session.New(&cfg) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if s == nil { + t.Fatal("New returned nil session") + } + + if s.ID() == "" { + t.Error("session ID is empty") + } +}