diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 20ade81..2752a23 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -14,7 +14,7 @@ TAU (Tailored Agentic Units) kernel — agent runtime with integrated subsystems | Validate | `go vet ./...` | | Proto lint | `cd rpc && buf lint` | | Proto generate | `cd rpc && buf generate` | -| Kernel (Ollama) | `go run cmd/kernel/main.go -config cmd/kernel/agent.ollama.qwen3.json -prompt "..."` | +| Kernel (Ollama) | `go run ./cmd/kernel/ -config cmd/kernel/agent.ollama.qwen3.json -prompt "..."` | | Prompt (Ollama) | `go run cmd/prompt-agent/main.go -config cmd/prompt-agent/agent.ollama.qwen3.json -prompt "..." -stream` | | Ollama | `docker compose up -d` | diff --git a/.claude/context/guides/.archive/15-runnable-kernel-cli.md b/.claude/context/guides/.archive/15-runnable-kernel-cli.md new file mode 100644 index 0000000..8d54853 --- /dev/null +++ b/.claude/context/guides/.archive/15-runnable-kernel-cli.md @@ -0,0 +1,476 @@ +# 15 — Runnable Kernel CLI with Built-in Tools + +## Problem Context + +The `cmd/kernel/main.go` is a stub. All kernel subsystems (session, memory, tools, kernel loop) are complete. This task replaces the stub with a functional CLI that exercises the full agentic loop against a real LLM — providing runtime validation of the entire kernel stack. + +## Architecture Approach + +Two files in `cmd/kernel/`: a tools file that defines and registers built-in tools with the global registry, and a main file that wires config loading, tool registration, kernel creation, and formatted output. Follows the proven `cmd/prompt-agent/main.go` pattern for CLI structure. + +Built-in tools live in `cmd/kernel/` (not in the `tools/` library) because they're CLI demo tools, not part of the kernel's core API. + +A seed memory directory at `cmd/kernel/memory/` with an identity file exercises the full `buildSystemContent()` path — memory loading, system prompt composition — so every kernel subsystem is validated at runtime. + +## Implementation + +### Step 1: Support unlimited iterations in `kernel/kernel.go` + +Change the `Run` loop from `for iteration := range k.maxIterations` to a traditional for-loop that treats 0 as unlimited. The context cancellation provides the safety net. + +In `kernel/kernel.go`, replace the loop header at line 141: + +```go +// before +for iteration := range k.maxIterations { + +// after +for iteration := 0; k.maxIterations == 0 || iteration < k.maxIterations; iteration++ { +``` + +Semantics: +- **0** → run until the agent produces a final response (or context cancellation) +- **N > 0** → bounded to N iterations, returns `ErrMaxIterations` if exhausted + +### Step 2: Create `cmd/kernel/tools.go` + +New file. Registers 3 built-in tools with the global `tools.Register`. + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/tailored-agentic-units/kernel/core/protocol" + "github.com/tailored-agentic-units/kernel/tools" +) + +func registerBuiltinTools() { + must(tools.Register(protocol.Tool{ + Name: "datetime", + Description: "Returns the current date and time in RFC3339 format.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, handleDatetime)) + + must(tools.Register(protocol.Tool{ + Name: "read_file", + Description: "Reads the contents of a file at the given path.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute or relative path to the file to read.", + }, + }, + "required": []string{"path"}, + }, + }, handleReadFile)) + + must(tools.Register(protocol.Tool{ + Name: "list_directory", + Description: "Lists files and directories at the given path.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute or relative path to the directory to list.", + }, + }, + "required": []string{"path"}, + }, + }, handleListDirectory)) +} + +func must(err error) { + if err != nil { + panic(fmt.Sprintf("failed to register tool: %v", err)) + } +} + +func handleDatetime(_ context.Context, _ json.RawMessage) (tools.Result, error) { + return tools.Result{Content: time.Now().Format(time.RFC3339)}, nil +} + +func handleReadFile(_ context.Context, raw json.RawMessage) (tools.Result, error) { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return tools.Result{Content: "invalid arguments: " + err.Error(), IsError: true}, nil + } + if args.Path == "" { + return tools.Result{Content: "path is required", IsError: true}, nil + } + + data, err := os.ReadFile(args.Path) + if err != nil { + return tools.Result{Content: err.Error(), IsError: true}, nil + } + return tools.Result{Content: string(data)}, nil +} + +func handleListDirectory(_ context.Context, raw json.RawMessage) (tools.Result, error) { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return tools.Result{Content: "invalid arguments: " + err.Error(), IsError: true}, nil + } + if args.Path == "" { + args.Path = "." + } + + entries, err := os.ReadDir(args.Path) + if err != nil { + return tools.Result{Content: err.Error(), IsError: true}, nil + } + + var b strings.Builder + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + b.WriteString(name) + b.WriteByte('\n') + } + return tools.Result{Content: b.String()}, nil +} +``` + +### Step 3: Replace `cmd/kernel/main.go` + +Replace the stub with a functional CLI. + +```go +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + + "github.com/tailored-agentic-units/kernel/kernel" +) + +func main() { + var ( + configFile = flag.String("config", "", "Path to kernel config JSON file (required)") + prompt = flag.String("prompt", "", "Prompt to send to the agent (required)") + systemPrompt = flag.String("system-prompt", "", "System prompt (overrides config)") + memoryPath = flag.String("memory", "", "Path to memory directory (overrides config)") + maxIterations = flag.Int("max-iterations", -1, "Maximum loop iterations; 0 for unlimited (overrides config)") + ) + flag.Parse() + + if *configFile == "" || *prompt == "" { + fmt.Fprintln(os.Stderr, "Usage: kernel -config -prompt ") + flag.PrintDefaults() + os.Exit(1) + } + + cfg, err := kernel.LoadConfig(*configFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + if *systemPrompt != "" { + cfg.SystemPrompt = *systemPrompt + } + if *memoryPath != "" { + cfg.Memory.Path = *memoryPath + } + if *maxIterations >= 0 { + cfg.MaxIterations = *maxIterations + } + + registerBuiltinTools() + + k, err := kernel.New(cfg) + if err != nil { + log.Fatalf("Failed to create kernel: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + result, err := k.Run(ctx, *prompt) + if err != nil { + log.Fatalf("Kernel run failed: %v", err) + } + + fmt.Printf("Response: %s\n", result.Response) + + if len(result.ToolCalls) > 0 { + fmt.Println("\nTool Calls:") + for i, tc := range result.ToolCalls { + fmt.Printf(" [%d] %s(%s)\n", i+1, tc.Name, tc.Arguments) + if tc.IsError { + fmt.Printf(" error: %s\n", tc.Result) + } else if len(tc.Result) > 200 { + fmt.Printf(" → %s...\n", tc.Result[:200]) + } else { + fmt.Printf(" → %s\n", tc.Result) + } + } + } + + fmt.Printf("\nIterations: %d\n", result.Iterations) +} +``` + +### Step 4: Create `cmd/kernel/memory/identity.md` + +New file. Seed memory content that augments the system prompt via the memory subsystem. + +```markdown +# Kernel Agent + +You are a TAU kernel agent running locally. You have access to tools for interacting with the filesystem and checking the current time. When asked questions, use your tools to find accurate answers rather than guessing. +``` + +### Step 5: Update `cmd/kernel/agent.ollama.qwen3.json` + +Add the memory path so memory loads by default without requiring the `-memory` flag. + +Add to the existing config JSON (sibling of `"agent"`, `"max_iterations"`, `"system_prompt"`): + +```json +"memory": { + "path": "cmd/kernel/memory" +} +``` + +## Remediation + +Steps required to clear blockers discovered during implementation. + +### R1: Add `MarshalJSON` to `ToolCall` in `core/protocol/message.go` + +The `ToolCall` type has a custom `UnmarshalJSON` that flattens the nested API format (`{function: {name, arguments}}`) into flat fields (`{name, arguments}`). However, there is no corresponding `MarshalJSON` — so when assistant messages containing tool calls are replayed back to the provider, they serialize in the flat format, which Ollama's OpenAI-compatible endpoint rejects as invalid. + +Add a `MarshalJSON` method that produces the nested format with `type: "function"`: + +```go +func (tc ToolCall) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + ID: tc.ID, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Name, + Arguments: tc.Arguments, + }, + }) +} +``` + +This ensures round-trip fidelity: provider responses decode correctly via `UnmarshalJSON`, and replayed messages serialize correctly via `MarshalJSON`. + +### R2: Add `*slog.Logger` to `Kernel` with `WithLogger` option + +Add a `*slog.Logger` field to the `Kernel` struct in `kernel/kernel.go` with a discard logger as the default. Add a `WithLogger` functional option. + +```go +// in imports +"io" +"log/slog" +``` + +Add field to `Kernel` struct: + +```go +log *slog.Logger +``` + +Default in `New` (alongside other field initializations): + +```go +log: slog.New(slog.NewTextHandler(io.Discard, nil)), +``` + +Add functional option: + +```go +func WithLogger(l *slog.Logger) Option { + return func(k *Kernel) { k.log = l } +} +``` + +### R3: Add log points in `buildSystemContent` and `Run` + +In `buildSystemContent`, replace the entry iteration loop: + +```go +// before +for _, entry := range entries { + content += "\n\n" + string(entry.Value) +} + +// after +for _, entry := range entries { + k.log.Debug("memory loaded", "key", entry.Key, "bytes", len(entry.Value)) + content += "\n\n" + string(entry.Value) +} +``` + +In `Run`, the full method with log points integrated: + +```go +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 + } + + k.log.Info("run started", "prompt_length", len(prompt), "max_iterations", k.maxIterations, "tools", len(k.tools.List())) + + for iteration := 0; k.maxIterations == 0 || iteration < k.maxIterations; iteration++ { + if err := ctx.Err(); err != nil { + return result, err + } + + k.log.Debug("iteration started", "iteration", iteration+1) + + 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 + k.log.Info("run complete", "iterations", iteration+1, "response_length", len(result.Response)) + 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 { + k.log.Debug("tool call", "iteration", iteration+1, "name", tc.Name) + + 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 + } + + k.log.Warn("max iterations reached", "iterations", k.maxIterations) + return result, ErrMaxIterations +} +``` + +### R4: Add `-verbose` flag to CLI + +In `cmd/kernel/main.go`, add a `-verbose` flag that creates an `slog.Logger` writing to stderr at the appropriate level, and pass it via `kernel.WithLogger`. + +```go +verbose = flag.Bool("verbose", false, "Enable verbose logging to stderr") +``` + +After flag parsing, before `kernel.New`: + +```go +var logger *slog.Logger +if *verbose { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) +} else { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) +} +``` + +Pass to kernel: + +```go +k, err := kernel.New(cfg, kernel.WithLogger(logger)) +``` + +## Validation Criteria + +- [ ] `go vet ./...` passes +- [ ] `go test ./...` passes (existing tests unbroken) +- [ ] `go run ./cmd/kernel/ -config cmd/kernel/agent.ollama.qwen3.json -prompt "What time is it?"` produces a response with tool call log +- [ ] Built-in tools are registered and callable by the LLM +- [ ] Output shows response text, tool call log, and iteration count +- [ ] `-verbose` flag shows memory loading and iteration sequence on stderr diff --git a/.claude/context/sessions/15-runnable-kernel-cli.md b/.claude/context/sessions/15-runnable-kernel-cli.md new file mode 100644 index 0000000..ff00900 --- /dev/null +++ b/.claude/context/sessions/15-runnable-kernel-cli.md @@ -0,0 +1,46 @@ +# 15 — Runnable Kernel CLI with Built-in Tools + +## Summary + +Replaced the `cmd/kernel/main.go` stub with a functional CLI entry point that exercises the full agentic loop against a real LLM. Added three built-in tools (datetime, read_file, list_directory), seed memory for system prompt composition, and structured logging via `slog`. Fixed a ToolCall serialization bug that blocked provider communication. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Built-in tools location | `cmd/kernel/` (not `tools/`) | CLI demo tools, not part of the kernel library API | +| Memory seed directory | `cmd/kernel/memory/` | Co-located with config; exercises full memory → system prompt pipeline | +| Unlimited iterations | `maxIterations=0` means run until done | Clean semantic; context cancellation provides safety net | +| Logger interface | `*slog.Logger` via `WithLogger` option | Standard library, zero dependencies, supports future per-subsystem logging | +| ToolCall MarshalJSON | Value receiver, nested format | Round-trip fidelity with UnmarshalJSON; value receiver works in all serialization contexts | +| CLI max-iterations flag | Default -1 (sentinel) | Distinguishes "not provided" from "unlimited" (0) | + +## Files Modified + +- `cmd/kernel/main.go` — replaced stub with functional CLI +- `cmd/kernel/tools.go` — built-in tool definitions and registration (new) +- `cmd/kernel/memory/identity.md` — seed memory content (new) +- `cmd/kernel/agent.ollama.qwen3.json` — added memory path +- `kernel/kernel.go` — unlimited iterations, `WithLogger`, slog log points +- `core/protocol/message.go` — `ToolCall.MarshalJSON` for nested API format +- `core/protocol/protocol_test.go` — MarshalJSON and round-trip tests +- `kernel/kernel_test.go` — unlimited iterations and WithLogger tests +- `_project/README.md` — kernel status updated to Complete +- `_project/objective.md` — issue #15 status updated to Closed +- `.claude/CLAUDE.md` — fixed kernel run command +- `.claude/skills/kernel-dev/SKILL.md` — added WithLogger to kernel exports +- `README.md` — fixed kernel run command + +## Patterns Established + +- **Remediation convention**: Implementation guides gain a Remediation section (R1, R2, ...) between final step and Validation Criteria for blockers discovered during execution +- **slog logger pattern**: Kernel accepts `*slog.Logger` via `WithLogger`; discard logger default; subsystems will follow this pattern (tracked in Objective #4) +- **CLI flag sentinel**: Use -1 default for optional numeric overrides where 0 has semantic meaning + +## Validation Results + +- `go vet ./...` — clean +- `go test ./...` — all pass +- `go mod tidy` — no changes +- Coverage: kernel.go Run 100%, message.go MarshalJSON 100%, kernel package 94.2%, protocol package 92.3% +- Manual: CLI runs against Ollama/Qwen3 with tool calls, memory loading, and verbose logging diff --git a/.claude/plans/dazzling-singing-abelson.md b/.claude/plans/dazzling-singing-abelson.md new file mode 100644 index 0000000..7f30e6b --- /dev/null +++ b/.claude/plans/dazzling-singing-abelson.md @@ -0,0 +1,66 @@ +# Issue #15 — Runnable Kernel CLI with Built-in Tools + +## Context + +The `cmd/kernel/main.go` is a stub printing "kernel: under development". All kernel subsystems are complete (session, memory, tools, kernel loop). This task replaces the stub with a functional CLI that exercises the full agentic loop against a real LLM, providing runtime validation of the entire kernel stack. + +## Approach + +Two new/modified files in `cmd/kernel/`: + +### 1. `cmd/kernel/tools.go` — Built-in tool definitions and registration + +A `registerBuiltinTools()` function that registers 3 tools with the global `tools.Register`: + +| Tool | Args | Implementation | +|------|------|----------------| +| `datetime` | none | `time.Now().Format(time.RFC3339)` | +| `read_file` | `{"path": "string"}` | `os.ReadFile(path)` | +| `list_directory` | `{"path": "string"}` | `os.ReadDir(path)`, format as newline-separated names | + +Each tool defined as `protocol.Tool` with JSON Schema parameters. Handlers are `tools.Handler` functions. + +### 2. `cmd/kernel/main.go` — Functional CLI entry point + +Following the `cmd/prompt-agent/main.go` pattern: + +**Flags:** +- `-config` (required) — path to config JSON +- `-prompt` (required) — user prompt +- `-system-prompt` — override config value +- `-memory` — path to memory directory (override `memory.path`) +- `-max-iterations` — override config value + +**Flow:** +1. Parse flags, validate required +2. `kernel.LoadConfig(configFile)` +3. Apply flag overrides to loaded config +4. `registerBuiltinTools()` +5. `kernel.New(&cfg)` → `kernel.Run(ctx, prompt)` +6. Print formatted output: response text, iteration count, tool call log + +**Output format:** +``` +Response: + +Tool Calls: + [1] datetime() → 2026-02-16T... + [2] read_file({"path":"go.mod"}) → module github.com/... + +Iterations: 3 +``` + +### Files Modified + +- `cmd/kernel/main.go` — replace stub (existing) +- `cmd/kernel/tools.go` — new file + +### Config File + +`cmd/kernel/agent.ollama.qwen3.json` already exists with correct Ollama/Qwen3 config. No changes needed. + +### Verification + +1. `go vet ./...` passes +2. `go test ./...` passes (no new tests needed for CLI main — runtime validation is the test) +3. Manual: `go run cmd/kernel/main.go -config cmd/kernel/agent.ollama.qwen3.json -prompt "What time is it?"` produces response with tool call log diff --git a/.claude/skills/kernel-dev/SKILL.md b/.claude/skills/kernel-dev/SKILL.md index 24f35dd..df430bd 100644 --- a/.claude/skills/kernel-dev/SKILL.md +++ b/.claude/skills/kernel-dev/SKILL.md @@ -67,7 +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` | +| `kernel` | Agent runtime loop | `Kernel`, `Config`, `Result`, `ToolExecutor`, `WithLogger` | ## Extension Patterns diff --git a/CHANGELOG.md b/CHANGELOG.md index ad79587..8fc25fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v0.1.0-dev.1.15 + +### kernel + +- Replace `cmd/kernel/main.go` stub with functional CLI entry point (#15) +- Add built-in tools: `datetime`, `read_file`, `list_directory` (#15) +- Add seed memory directory at `cmd/kernel/memory/` for system prompt composition (#15) +- Support unlimited iterations when `maxIterations` is 0 (#15) +- Add `WithLogger` option with `*slog.Logger` for runtime observability (#15) +- Add structured log points in `Run` and `buildSystemContent` (#15) + +### core + +- Add `ToolCall.MarshalJSON` for nested LLM API format round-trip fidelity (#15) + ## v0.1.0-dev.1.14 ### kernel diff --git a/README.md b/README.md index 8a6a378..2831341 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Proto definitions live in `rpc/proto/`, generated code in `rpc/gen/`. docker compose up -d # Run the kernel with a prompt -go run cmd/kernel/main.go \ +go run ./cmd/kernel/ \ -config cmd/kernel/agent.ollama.qwen3.json \ -prompt "What time is it?" diff --git a/_project/README.md b/_project/README.md index 1e3aabc..deffc0c 100644 --- a/_project/README.md +++ b/_project/README.md @@ -45,7 +45,7 @@ Extension ecosystem (external services connecting through the interface): | **tools** | Tool system: global registry with Register, Execute, List | core | Complete | | **session** | Conversation management: Session interface, in-memory implementation | core | Complete | | **mcp** | MCP client: transport abstraction, tool discovery, stdio/SSE | tools | Skeleton | -| **kernel** | Agent runtime: agentic loop, config-driven initialization | all above | Runtime loop | +| **kernel** | Agent runtime: agentic loop, config-driven initialization, CLI entry point | all above | Complete | ## Dependency Hierarchy diff --git a/_project/objective.md b/_project/objective.md index 8cd089d..ee54d0c 100644 --- a/_project/objective.md +++ b/_project/objective.md @@ -15,7 +15,7 @@ Implement the agentic processing loop — the core observe/think/act/repeat cycl | 12 | Tool registry interface and execution | Closed | | 13 | Memory store interface and filesystem implementation | Closed | | 14 | Kernel runtime loop | Closed | -| 15 | Runnable kernel CLI with built-in tools | Open | +| 15 | Runnable kernel CLI with built-in tools | Closed | ## Architecture Decisions diff --git a/cmd/kernel/agent.ollama.qwen3.json b/cmd/kernel/agent.ollama.qwen3.json index 2adb670..8ec1892 100644 --- a/cmd/kernel/agent.ollama.qwen3.json +++ b/cmd/kernel/agent.ollama.qwen3.json @@ -33,6 +33,9 @@ } } }, + "memory": { + "path": "cmd/kernel/memory" + }, "max_iterations": 10, "system_prompt": "You are a helpful assistant. Use the available tools when they would help answer the user's question." } diff --git a/cmd/kernel/main.go b/cmd/kernel/main.go index 877d976..342bcc1 100644 --- a/cmd/kernel/main.go +++ b/cmd/kernel/main.go @@ -1,7 +1,90 @@ package main -import "fmt" +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "os" + "os/signal" + + "github.com/tailored-agentic-units/kernel/kernel" +) func main() { - fmt.Println("kernel: under development") + var ( + configFile = flag.String("config", "", "Path to kernel config JSON file (required)") + prompt = flag.String("prompt", "", "Prompt to send to the agent (required)") + systemPrompt = flag.String("system-prompt", "", "System prmopt (overrides config)") + memoryPath = flag.String("memory", "", "Path to memory directory (overrides config)") + maxIterations = flag.Int("max-iterations", -1, "Maximum loop iterations; 0 for unlimited (overrides config)") + verbose = flag.Bool("verbose", false, "Enable verbose logging to stderr") + ) + flag.Parse() + + if *configFile == "" || *prompt == "" { + fmt.Fprintln(os.Stderr, "Usage: kernel -config -prompt ") + flag.PrintDefaults() + os.Exit(1) + } + + cfg, err := kernel.LoadConfig(*configFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + if *systemPrompt != "" { + cfg.SystemPrompt = *systemPrompt + } + if *memoryPath != "" { + cfg.Memory.Path = *memoryPath + } + if *maxIterations >= 0 { + cfg.MaxIterations = *maxIterations + } + + var logger *slog.Logger + if *verbose { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + } else { + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + } + + registerBuiltinTools() + + runtime, err := kernel.New(cfg, kernel.WithLogger(logger)) + if err != nil { + log.Fatalf("Failed to create kernel runtime: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + result, err := runtime.Run(ctx, *prompt) + if err != nil { + log.Fatalf("Kernel run failed: %v", err) + } + + fmt.Printf("Response: %s\n", result.Response) + + if len(result.ToolCalls) > 0 { + fmt.Println("\nTool Calls:") + for i, tc := range result.ToolCalls { + fmt.Printf(" [%d] %s(%s)\n", i+1, tc.Name, tc.Arguments) + if tc.IsError { + fmt.Printf(" error: %s\n", tc.Result) + } else if len(tc.Result) > 200 { + fmt.Printf(" -> %s...\n", tc.Result[:200]) + } else { + fmt.Printf(" -> %s\n", tc.Result) + } + } + } + + fmt.Printf("\nIterations: %d\n", result.Iterations) } diff --git a/cmd/kernel/memory/identity.md b/cmd/kernel/memory/identity.md new file mode 100644 index 0000000..7432769 --- /dev/null +++ b/cmd/kernel/memory/identity.md @@ -0,0 +1,3 @@ +# Kernel Agent + +You are a TAU kernel agent running locally. You have access to tools for interacting with the filesystem and checking the current time. When asked questions, use your tools to find accurate answers rather than guessing. diff --git a/cmd/kernel/tools.go b/cmd/kernel/tools.go new file mode 100644 index 0000000..473338c --- /dev/null +++ b/cmd/kernel/tools.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/tailored-agentic-units/kernel/core/protocol" + "github.com/tailored-agentic-units/kernel/tools" +) + +func registerBuiltinTools() { + must(tools.Register(protocol.Tool{ + Name: "datetime", + Description: "Returns the current date and time in RFC3339 format.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, handleDatetime)) + + must(tools.Register(protocol.Tool{ + Name: "read_file", + Description: "Reads the contents of a file at the given path.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute or relative path to the file to read.", + }, + }, + "required": []string{"path"}, + }, + }, handleReadFile)) + + must(tools.Register(protocol.Tool{ + Name: "list_directory", + Description: "Lists files and directories at the given path.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Abssolute or relative path to the directory to list.", + }, + }, + "required": []string{"path"}, + }, + }, handleListDirectory)) +} + +func must(err error) { + if err != nil { + panic(fmt.Sprintf("failed to register tool: %v", err)) + } +} + +func handleDatetime(_ context.Context, _ json.RawMessage) (tools.Result, error) { + return tools.Result{Content: time.Now().Format(time.RFC3339)}, nil +} + +func handleReadFile(_ context.Context, raw json.RawMessage) (tools.Result, error) { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return tools.Result{Content: "invalid arguments: " + err.Error(), IsError: true}, nil + } + if args.Path == "" { + return tools.Result{Content: "path is required", IsError: true}, nil + } + + data, err := os.ReadFile(args.Path) + if err != nil { + return tools.Result{Content: err.Error(), IsError: true}, nil + } + return tools.Result{Content: string(data)}, nil +} + +func handleListDirectory(_ context.Context, raw json.RawMessage) (tools.Result, error) { + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &args); err != nil { + return tools.Result{Content: "invalid arguments: " + err.Error(), IsError: true}, nil + } + if args.Path == "" { + args.Path = "." + } + + entries, err := os.ReadDir(args.Path) + if err != nil { + return tools.Result{Content: err.Error(), IsError: true}, nil + } + + var b strings.Builder + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + b.WriteString(name) + b.WriteByte('\n') + } + return tools.Result{Content: b.String()}, nil +} diff --git a/core/protocol/message.go b/core/protocol/message.go index 99b79b9..b4f3c7f 100644 --- a/core/protocol/message.go +++ b/core/protocol/message.go @@ -22,6 +22,29 @@ type ToolCall struct { Arguments string `json:"arguments"` } +// MarshalJSON serializes to the nested LLM API format ({type, function: {name, arguments}}) +// ensuring round-trip fidelity with UnmarshalJSON for provider communication. +func (tc ToolCall) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + }{ + ID: tc.ID, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Name, + Arguments: tc.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. diff --git a/core/protocol/protocol_test.go b/core/protocol/protocol_test.go index c55946d..55051fc 100644 --- a/core/protocol/protocol_test.go +++ b/core/protocol/protocol_test.go @@ -340,6 +340,78 @@ func TestToolCall_UnmarshalJSON_InArray(t *testing.T) { } } +func TestToolCall_MarshalJSON_NestedFormat(t *testing.T) { + tc := protocol.ToolCall{ + ID: "call_789", + Name: "get_weather", + Arguments: `{"location":"Boston"}`, + } + + data, err := json.Marshal(tc) + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if raw["id"] != "call_789" { + t.Errorf("got id %v, want %q", raw["id"], "call_789") + } + if raw["type"] != "function" { + t.Errorf("got type %v, want %q", raw["type"], "function") + } + + fn, ok := raw["function"].(map[string]any) + if !ok { + t.Fatalf("function field is not an object: %T", raw["function"]) + } + if fn["name"] != "get_weather" { + t.Errorf("got function.name %v, want %q", fn["name"], "get_weather") + } + if fn["arguments"] != `{"location":"Boston"}` { + t.Errorf("got function.arguments %v, want %q", fn["arguments"], `{"location":"Boston"}`) + } + + // Verify flat fields are NOT present at top level + if _, exists := raw["name"]; exists { + t.Error("name should not be at top level in nested format") + } + if _, exists := raw["arguments"]; exists { + t.Error("arguments should not be at top level in nested format") + } +} + +func TestToolCall_MarshalJSON_RoundTrip(t *testing.T) { + original := protocol.ToolCall{ + ID: "call_rt", + Name: "search", + Arguments: `{"query":"test","limit":10}`, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + + var restored protocol.ToolCall + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if restored.ID != original.ID { + t.Errorf("ID: got %q, want %q", restored.ID, original.ID) + } + if restored.Name != original.Name { + t.Errorf("Name: got %q, want %q", restored.Name, original.Name) + } + if restored.Arguments != original.Arguments { + t.Errorf("Arguments: got %q, want %q", restored.Arguments, original.Arguments) + } +} + func TestInitMessages(t *testing.T) { messages := protocol.InitMessages(protocol.RoleUser, "Hello") diff --git a/kernel/kernel.go b/kernel/kernel.go index dcbe0be..61f8272 100644 --- a/kernel/kernel.go +++ b/kernel/kernel.go @@ -12,6 +12,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "log/slog" "github.com/tailored-agentic-units/kernel/agent" "github.com/tailored-agentic-units/kernel/core/protocol" @@ -78,12 +80,18 @@ func WithMemoryStore(s memory.Store) Option { return func(k *Kernel) { k.store = s } } +// WithLogger overrides the default discard logger for runtime observability. +func WithLogger(l *slog.Logger) Option { + return func(k *Kernel) { k.log = l } +} + // 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 + log *slog.Logger maxIterations int systemPrompt string } @@ -112,6 +120,7 @@ func New(cfg *Config, opts ...Option) (*Kernel, error) { session: sesh, store: store, tools: globalToolExecutor{}, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), maxIterations: cfg.MaxIterations, systemPrompt: cfg.SystemPrompt, } @@ -125,7 +134,9 @@ func New(cfg *Config, opts ...Option) (*Kernel, error) { // 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. +// When maxIterations is 0, the loop runs until the agent produces a final +// response or the context is cancelled. Returns ErrMaxIterations if a non-zero +// iteration budget is exhausted. func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { k.session.AddMessage( protocol.NewMessage(protocol.RoleUser, prompt), @@ -138,11 +149,15 @@ func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { return result, err } - for iteration := range k.maxIterations { + k.log.Info("run started", "prompt_length", len(prompt), "max_iterations", k.maxIterations, "tools", len(k.tools.List())) + + for iteration := 0; k.maxIterations == 0 || iteration < k.maxIterations; iteration++ { if err := ctx.Err(); err != nil { return result, err } + k.log.Debug("iteration started", "iteration", iteration+1) + messages := k.buildMessages(systemContent) resp, err := k.agent.Tools(ctx, messages, k.tools.List()) @@ -163,6 +178,9 @@ func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { }) result.Response = choice.Message.Content result.Iterations = iteration + 1 + + k.log.Info("run complete", "iterations", iteration+1, "response_length", len(result.Response)) + return result, nil } @@ -173,6 +191,8 @@ func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { }) for _, tc := range choice.Message.ToolCalls { + k.log.Debug("tool call", "iteration", iteration+1, "name", tc.Name) + record := ToolCallRecord{ Iteration: iteration + 1, ID: tc.ID, @@ -211,6 +231,8 @@ func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { result.Iterations = iteration + 1 } + k.log.Warn("max iterations reached", "iterations", k.maxIterations) + return result, ErrMaxIterations } @@ -248,6 +270,7 @@ func (k *Kernel) buildSystemContent(ctx context.Context) (string, error) { } for _, entry := range entries { + k.log.Debug("memory loaded", "key", entry.Key, "bytes", len(entry.Value)) content += "\n\n" + string(entry.Value) } diff --git a/kernel/kernel_test.go b/kernel/kernel_test.go index 85cac97..f65e156 100644 --- a/kernel/kernel_test.go +++ b/kernel/kernel_test.go @@ -1,9 +1,12 @@ package kernel_test import ( + "bytes" "context" "encoding/json" "errors" + "log/slog" + "strings" "sync/atomic" "testing" @@ -685,6 +688,96 @@ func TestRun_ToolCallRecordFields(t *testing.T) { } } +func TestRun_UnlimitedIterations(t *testing.T) { + // With maxIterations=0, the loop should run until the agent produces a + // final response rather than stopping after zero iterations. + agent := newSequentialAgent( + []*response.ToolsResponse{ + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_1", Name: "step", Arguments: `{}`}, + }), + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_2", Name: "step", Arguments: `{}`}, + }), + makeToolsResponse([]protocol.ToolCall{ + {ID: "call_3", Name: "step", Arguments: `{}`}, + }), + makeFinalResponse("finished after 4 iterations"), + }, + nil, + ) + + executor := &mockToolExecutor{ + handler: func(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + return tools.Result{Content: "ok"}, nil + }, + } + + cfg := minimalConfig() + cfg.MaxIterations = 0 + + 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(), "Run until done") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if result.Response != "finished after 4 iterations" { + t.Errorf("got response %q, want %q", result.Response, "finished after 4 iterations") + } + + if result.Iterations != 4 { + t.Errorf("got %d iterations, want 4", result.Iterations) + } + + if len(result.ToolCalls) != 3 { + t.Errorf("got %d tool calls, want 3", len(result.ToolCalls)) + } +} + +func TestWithLogger(t *testing.T) { + agent := newSequentialAgent( + []*response.ToolsResponse{makeFinalResponse("ok")}, + nil, + ) + + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + k, err := kernel.New(minimalConfig(), + kernel.WithAgent(agent), + kernel.WithSession(newTestSession()), + kernel.WithToolExecutor(&mockToolExecutor{}), + kernel.WithLogger(logger), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = k.Run(context.Background(), "Hello") + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "run started") { + t.Error("expected 'run started' log entry") + } + if !strings.Contains(output, "run complete") { + t.Error("expected 'run complete' log entry") + } +} + // --- Helper types --- // messageCapturingAgent wraps sequentialAgent to capture the messages passed to Tools.