diff --git a/README.md b/README.md index 5ee844d3..b15b1ce9 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,23 @@ # go-code -`go-code` is a local-first coding agent for working inside real repositories. It gives you an installable terminal command, a TUI for interactive development, a streamed HTTP runtime, provider and model routing, workspace-aware tool calls, and enough visibility to understand what the agent is doing while it works. +`go-code` is a local-first coding agent written in Go. One installed command gives you the same agent three ways: an interactive TUI for daily development, a single-shot CLI for scripting, and a streamed HTTP API for building on top of — all running in the repository where you launched it, against the model provider you choose. -The project is implemented in Go. The public command is `go-code`; the internal module name is still `go-agent-harness` while the product surface settles. +There is no hosted service or separate control plane. The TUI and CLI are thin clients over one public HTTP/SSE API: `go-code` auto-starts the local `harnessd` daemon when needed, streams every model turn, tool call, and approval as events, and records runs so you can inspect, continue, replay, and search them later. -## What You Get +## Use the TUI -- `go-code`: launch the TUI from any repository. -- `go-code "prompt"`: run one coding prompt and stream the result. -- `go-code --server`: start the local `harnessd` daemon and leave it running. -- A streamed event API for runs, tool calls, model usage, approvals, subagents, conversations, and replay. -- Provider catalog support for OpenAI, Anthropic, Google, DeepSeek, Z.ai, OpenRouter-style routes, and local catalog pricing. -- Workspace-aware execution so the agent works in the project directory where you launched it. - -## Install - -Install the latest `main` build with Homebrew: +Install the latest `main` build with Homebrew and launch it from any repository: ```bash brew install --HEAD dennisonbertram/go-code/go-code -``` - -Then run it from any repository: - -```bash +export OPENAI_API_KEY="..." go-code ``` -Source install is still available if you do not use Homebrew: +The TUI runs the agent in the project directory where you launched it, streaming assistant output and tool activity as it works. A daemon is started automatically if one is not already running; a pre-existing server is left alone. + +If you do not use Homebrew, install from source: ```bash git clone https://github.com/dennisonbertram/go-code.git @@ -39,15 +28,15 @@ cd go-code ./scripts/install.sh --add-to-path ``` -Open a new shell, or add the printed PATH line for your current shell. +Then open a new shell, or add the printed PATH line to your current one. + +## Script it from the shell -Common modes: +For one-shot prompts and run management, the same command works headlessly: ```bash -go-code # interactive TUI in the current project -go-code "summarize this repo" # single-shot prompt -go-code --server # persistent local daemon -go-code runs # list known runs +go-code "summarize this repo" # single-shot prompt, streamed to stdout +go-code runs # list known runs go-code show # inspect one run go-code continue "..." # continue and stream a completed run go-code replay # replay a recorded run when rollout capture is enabled @@ -55,13 +44,39 @@ go-code search # search run metadata go-code improve --dry-run # plan the self-improvement test loop ``` -Completed runs also keep a searchable workflow recap when persistence is enabled: -goal, changed files, tests run, failure cause, fix pattern, useful commands, and -a continuation prompt. `go-code search ` matches those recap fields. +When persistence is enabled, completed runs keep a searchable workflow recap — goal, changed files, tests run, failure cause, fix pattern, useful commands, and a continuation prompt. `go-code search ` matches those recap fields. -## API Keys +## Build on the API -Set the provider keys you plan to use before starting a run: +Everything the TUI does goes through the daemon's streamed run API, which you can use directly: + +```bash +go-code --server # start harnessd in the background and print its URL +``` + +The most commonly used endpoints: + +```text +GET /healthz +GET /v1/models +GET /v1/providers +POST /v1/runs +GET /v1/runs/{id}/events +POST /v1/runs/{id}/continue +POST /v1/runs/{id}/steer +POST /v1/runs/{id}/compact +POST /v1/runs/{id}/cancel +GET /v1/conversations/ +GET /v1/skills +GET /v1/subagents +POST /v1/subagents +``` + +Run requests support prompt, model, provider, workspace, sandbox, approval, tool, profile, reasoning, and budget fields. Canonical event names live in `internal/harness/events.go`. + +## Pick your providers + +Set keys for the providers you plan to use — only those: ```bash export OPENAI_API_KEY="..." @@ -71,9 +86,11 @@ export DEEPSEEK_API_KEY="..." export ZAI_API_KEY="..." ``` -You only need keys for the providers you use. You can also configure provider keys through the TUI and server APIs as the harness evolves. +The provider catalog covers OpenAI, Anthropic, Google, DeepSeek, Z.ai, and OpenRouter-style routes, with local catalog pricing for cost tracking. Keys can also be configured through the TUI and server APIs. + +## Develop this repository -## Running From Source +The public command is `go-code`; the internal Go module is still named `go-agent-harness` while the product surface settles. For development or debugging, run the server and CLI directly: @@ -89,7 +106,9 @@ tmux new-session -d -s go-code-server 'cd /path/to/go-code && go run ./cmd/harne tmux attach-session -t go-code-server ``` -## Repository Map +### Layout + +The core of the system: - `cmd/harnesscli`: command-line client and terminal UI. - `cmd/harnessd`: local HTTP daemon and runtime bootstrap. @@ -98,43 +117,11 @@ tmux attach-session -t go-code-server - `internal/provider`: provider clients, model catalogs, pricing, and routing. - `internal/workspace`: local, container, VM, and worktree workspace implementations. - `catalog/`: model and pricing catalogs used at runtime. -- `prompts/`: bundled prompt assets installed with `go-code`. -- `apps/`: experimental apps that integrate with the harness. -- `benchmarks/`: Terminal Bench and overnight benchmark harnesses. -- `harness_agent/`: Python adapter used by benchmark runners. -- `skills/`: bundled skill fixtures and validation coverage. -- `demo/`: small static demos and smoke-test pages. -- `build/`: container/build packaging assets. -- `testdata/`: shared test fixtures. -- `playground/`: separate-module experiments and training exercises. - `docs/`: runbooks, design notes, logs, Pages source, and project context. -- `scripts/`: install, development, Symphony, and regression helpers. -The repo root is kept for product entrypoints and project metadata. Scratch snippets and exercises should live under `playground/` or a dedicated test fixture. - -## HTTP Surface - -The server exposes a streamed coding-agent API. The most commonly used endpoints are: - -```text -GET /healthz -GET /v1/models -GET /v1/providers -POST /v1/runs -GET /v1/runs/{id}/events -POST /v1/runs/{id}/continue -POST /v1/runs/{id}/steer -POST /v1/runs/{id}/compact -POST /v1/runs/{id}/cancel -GET /v1/conversations/ -GET /v1/skills -GET /v1/subagents -POST /v1/subagents -``` - -Run requests support prompt, model, provider, workspace, sandbox, approval, tool, profile, reasoning, and budget fields. Canonical event names live in `internal/harness/events.go`. +Supporting directories: `prompts/` (bundled prompt assets), `apps/` (experimental integrations), `benchmarks/` and `harness_agent/` (Terminal Bench harnesses; Python), `skills/` (bundled skill fixtures), `demo/` (static demos), `build/` (packaging assets), `testdata/` (shared fixtures), `playground/` (separate-module experiments), and `scripts/` (install, development, Symphony, and regression helpers). The repo root is kept for product entrypoints and project metadata; scratch snippets belong under `playground/` or a dedicated test fixture. -## Testing +### Testing Focused checks for the install and TUI path: @@ -151,7 +138,7 @@ GOCACHE=/tmp/go-build ./scripts/test-regression.sh Follow `docs/runbooks/testing.md` for strict TDD expectations, behavior tests, regression tests, and merge gates. -## Documentation +### Documentation - Public page source: `docs/site/` - Distribution runbook: `docs/runbooks/distribution.md` diff --git a/docs/design/nanocodex-comparison-plan.md b/docs/design/nanocodex-comparison-plan.md new file mode 100644 index 00000000..5e402ff4 --- /dev/null +++ b/docs/design/nanocodex-comparison-plan.md @@ -0,0 +1,72 @@ +# Cleaner Boundaries and Tool Ergonomics (nanocodex comparison) + +Status: Workstream A landed; B and C planned. + +A code comparison against [nanocodex](https://github.com/gakonst/nanocodex) — a +deliberately small, library-first Rust agents SDK — surfaced three improvements +worth adopting here, without giving up what makes go-code different (a local +product with a service API, multi-provider routing, approvals, persistence). + +## Workstream A — Typed tool registration (landed) + +Nanocodex's `#[tool]` macro derives the JSON schema from a typed function +signature. Our Go equivalent is `tools.NewTyped` / `tools.MustTyped` +(`internal/harness/tools/typed.go`): define a tool as a typed function over an +args struct and the Parameters schema is derived by reflection from the +struct's fields and tags (`json` name + omitempty/pointer optionality, `desc`, +`min`/`max`, `enum`). The schema and the decode target can no longer drift +apart, and per-tool boilerplate (unmarshal, error wrapping, result +marshalling) disappears. + +Pilot migrations: `glob` and `git_status`. Schema parity with the previous +hand-written literals is locked in by `typed_parity_test.go` (canonical-JSON +comparison), alongside behavior tests. Remaining hand-written tools migrate +opportunistically — `NewTyped` wraps the existing `Tool`/`Handler` types, so +both styles coexist and the registry, `ParallelSafe`/`Mutating` flags, policy +wrapping, and approval machinery are untouched. + +## Workstream B — Split `internal/harness` (planned) + +`internal/harness` is a single ~100k-line package with 124 root files; its +types file imports forensics, observational memory, store, and working memory +directly. Nanocodex's five-crate layout (dependency-light core / service / +tools / mcp / facade) is the model. Plan: + +1. **Inventory PR (no code moves):** script the intra-package dependency graph + and record the target package map here. Candidate clusters from the file + layout: `event` (events.go), `permission` (permission_rules, plan_mode), + `convstore` (conversation_store*, sqlite), `broker` (approval / ask-user / + checkpoint brokers), `registry` (registry.go + typed registration), with + the runner loop remaining in `harness`. +2. **Extract leaf-first, one cluster per PR, using type aliases** (`type X = + newpkg.X` left in `package harness`) so the ~30 importing packages are + untouched in the move PR. Each PR is small, CI-green, merged same day — + never a long-lived split branch (`main` squash-merges concurrently). +3. **Importer-update PRs** then retire the aliases cluster by cluster. +4. **Guardrail:** a lint or test asserting the extracted core types package + imports nothing heavier than stdlib, so the boundary stays true. + +Verification per PR: `go build ./internal/... ./cmd/...`, the key-free smoke +(`go test ./internal/server/... -run TestRunSmoke`), and +`scripts/test-regression.sh` before merge. + +## Workstream C — Public Go client (planned, after B's events extraction) + +An embeddable story shaped like nanocodex's three-line hello world, but honest +to our architecture: a typed client over the existing HTTP/SSE API rather than +an in-process runner. + +1. Graduate the extracted event/run-request wire types to a public package + (e.g. `pkg/gocode/wire`) that `internal/server` itself imports — a single + source of truth, no drift, no parity tests to maintain. This is why C waits + for B's events extraction. +2. Build `pkg/gocode`: a `Client` with `StartRun`, `Continue`, `Cancel`, and + an `Events(ctx, runID)` SSE iterator returning typed events. Marked + v0/experimental. +3. Integration-test against the fake provider (`HARNESS_PROVIDER=fake`). +4. Add a short Go example to README's "Build on the API" section. + +## Sequencing + +A ships first (this PR). B1 (inventory) and B2 (events extraction) next; C +starts once B2 lands; remaining B extractions continue in parallel with C. diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 1c9458df..32bf2849 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -1270,3 +1270,18 @@ Decision rule: when uncertain, default to `command intent` and `user intent` bel - Open questions: - None for this tier. - Next verification step: review the three-commit diff on `fix-harness-tier2-security`, then promote through the repo's normal verify-and-merge flow. + +## 2026-07-19 (Typed tool registration — Workstream A of the nanocodex-comparison plan) + +- Command intent: Land the first workstream of `docs/design/nanocodex-comparison-plan.md` on `claude/reader-cleanup-value-prop-spooyb`: a typed tool-registration helper (`tools.NewTyped`/`MustTyped`) that derives the Parameters JSON schema from a Go args struct, plus pilot migrations of `glob` and `git_status`, with exact schema parity to the previous hand-written literals. +- User intent: Close the DX gap with nanocodex's `#[tool]` macro — a tool's schema and its decode target should be one artifact that cannot drift — while leaving the registry, `ParallelSafe`/`Mutating` flags, policy wrapping, and approval machinery untouched. +- Success definition: + - `internal/harness/tools/typed.go` derives schemas by reflection (json-tag names, omitempty/pointer optionality, `desc`/`min`/`max`/`enum` tags, nested structs/slices/maps), decodes args (empty/null tolerated as zero value), and marshals results (string/RawMessage passthrough); proven by 8 `TestNewTyped*` tests including construction-error paths. + - Pilot migrations preserve wire-visible behavior: `typed_parity_test.go` locks the generated schemas against the pre-migration literals via canonical-JSON comparison, plus metadata (Action/ParallelSafe/Tags) and behavior tests (glob match/limit/error paths; git_status porcelain default via `*bool` since the old handler defaulted true only when the key was absent). + - Verification green: `go build ./internal/... ./cmd/...`, `go vet ./internal/harness/tools/`, full `./internal/harness/tools/...` suites, `./internal/server/... -run TestRunSmoke`, and harness `Registry|Catalog` tests. +- Non-goals: migrating the remaining ~50 hand-written tools (opportunistic, both styles coexist); Workstreams B (harness split) and C (public Go client) — planned in the design doc, not started. +- Guardrails/constraints: + - One deliberate error-message change: glob with empty raw args now fails "pattern is required" instead of a JSON parse error (the wrapper tolerates absent args); noted in tests. + - Known pre-existing failures, unrelated (verified via `git stash` on the unmodified tree): `benchmarks/terminal_bench/reference_solutions` packages don't build (no `main`), and `TestEnsureWorkspaceRootUsable/non-writable_root` fails when running as uid 0 (root writes to non-writable dirs). +- Open questions: whether `min`/`max`/`enum` tag coverage suffices for the wider migration or a `schema:"..."` escape hatch is needed — decide when migrating a tool that needs more. +- Next verification step: review the PR diff, then B1 (dependency-graph inventory) per the design doc's sequencing. diff --git a/internal/harness/tools/git_status.go b/internal/harness/tools/git_status.go index c5ef19c2..c976dc08 100644 --- a/internal/harness/tools/git_status.go +++ b/internal/harness/tools/git_status.go @@ -2,7 +2,6 @@ package tools import ( "context" - "encoding/json" "fmt" "path/filepath" "strings" @@ -11,54 +10,45 @@ import ( "go-agent-harness/internal/harness/tools/descriptions" ) +type gitStatusArgs struct { + // Porcelain is a pointer so an absent key keeps the porcelain default + // while an explicit false switches to human-readable output. + Porcelain *bool `json:"porcelain,omitempty"` +} + func gitStatusTool(workspaceRoot string) Tool { - def := Definition{ + return MustTyped(TypedSpec{ Name: "git_status", Description: descriptions.Load("git_status"), Action: ActionRead, ParallelSafe: true, Tags: []string{"git", "status", "repository", "staged", "modified"}, - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "porcelain": map[string]any{"type": "boolean"}, - }, - }, - } - - handler := func(ctx context.Context, raw json.RawMessage) (string, error) { - args := struct { - Porcelain bool `json:"porcelain"` - }{Porcelain: true} - if len(raw) > 0 { - if err := json.Unmarshal(raw, &args); err != nil { - return "", fmt.Errorf("parse git_status args: %w", err) - } + }, func(ctx context.Context, args gitStatusArgs) (any, error) { + porcelain := true + if args.Porcelain != nil { + porcelain = *args.Porcelain } absRoot, err := filepath.Abs(workspaceRoot) if err != nil { - return "", fmt.Errorf("resolve workspace root: %w", err) + return nil, fmt.Errorf("resolve workspace root: %w", err) } cmdArgs := []string{"-C", absRoot, "status"} - if args.Porcelain { + if porcelain { cmdArgs = append(cmdArgs, "--porcelain=v1") } output, exitCode, timedOut, err := runCommand(ctx, 30*time.Second, "git", cmdArgs...) if err != nil { - return "", fmt.Errorf("git status failed: %w", err) + return nil, fmt.Errorf("git status failed: %w", err) } trimmed := strings.TrimSpace(output) - result := map[string]any{ + return map[string]any{ "clean": trimmed == "", "output": trimmed, "exit_code": exitCode, "timed_out": timedOut, - } - return MarshalToolResult(result) - } - - return Tool{Definition: def, Handler: handler} + }, nil + }) } diff --git a/internal/harness/tools/glob.go b/internal/harness/tools/glob.go index 45c454d7..d85084ff 100644 --- a/internal/harness/tools/glob.go +++ b/internal/harness/tools/glob.go @@ -2,7 +2,6 @@ package tools import ( "context" - "encoding/json" "fmt" "path/filepath" "sort" @@ -11,33 +10,21 @@ import ( "go-agent-harness/internal/harness/tools/descriptions" ) +type globArgs struct { + Pattern string `json:"pattern" desc:"glob pattern relative to workspace"` + MaxMatches int `json:"max_matches,omitempty" min:"1" max:"2000"` +} + func globTool(workspaceRoot string, sandboxScope SandboxScope) Tool { - def := Definition{ + return MustTyped(TypedSpec{ Name: "glob", Description: descriptions.Load("glob"), Action: ActionList, ParallelSafe: true, Tags: []string{"glob", "find", "files", "pattern", "names", "wildcard"}, - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "pattern": map[string]any{"type": "string", "description": "glob pattern relative to workspace"}, - "max_matches": map[string]any{"type": "integer", "minimum": 1, "maximum": 2000}, - }, - "required": []string{"pattern"}, - }, - } - - handler := func(ctx context.Context, raw json.RawMessage) (string, error) { - args := struct { - Pattern string `json:"pattern"` - MaxMatches int `json:"max_matches"` - }{MaxMatches: 500} - if err := json.Unmarshal(raw, &args); err != nil { - return "", fmt.Errorf("parse glob args: %w", err) - } + }, func(ctx context.Context, args globArgs) (any, error) { if strings.TrimSpace(args.Pattern) == "" { - return "", fmt.Errorf("pattern is required") + return nil, fmt.Errorf("pattern is required") } if args.MaxMatches <= 0 { args.MaxMatches = 500 @@ -46,17 +33,17 @@ func globTool(workspaceRoot string, sandboxScope SandboxScope) Tool { args.MaxMatches = 2000 } if err := validateWorkspaceRelativePattern(args.Pattern); err != nil { - return "", err + return nil, err } absRoot, err := filepath.Abs(workspaceRoot) if err != nil { - return "", fmt.Errorf("resolve workspace root: %w", err) + return nil, fmt.Errorf("resolve workspace root: %w", err) } absPattern := filepath.Join(absRoot, filepath.FromSlash(args.Pattern)) matches, err := filepath.Glob(absPattern) if err != nil { - return "", fmt.Errorf("glob pattern: %w", err) + return nil, fmt.Errorf("glob pattern: %w", err) } scope := EffectiveSandboxScope(ctx, sandboxScope) @@ -83,12 +70,9 @@ func globTool(workspaceRoot string, sandboxScope SandboxScope) Tool { } sort.Strings(filtered) - result := map[string]any{ + return map[string]any{ "pattern": args.Pattern, "matches": filtered, - } - return MarshalToolResult(result) - } - - return Tool{Definition: def, Handler: handler} + }, nil + }) } diff --git a/internal/harness/tools/typed.go b/internal/harness/tools/typed.go new file mode 100644 index 00000000..8c553076 --- /dev/null +++ b/internal/harness/tools/typed.go @@ -0,0 +1,226 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" +) + +// TypedSpec carries the tool metadata that cannot be derived from the args +// type: identity, action classification, and discovery tags. Parameters are +// intentionally absent — NewTyped derives the JSON schema from the args +// struct so the schema and the decode target can never drift apart. +type TypedSpec struct { + Name string + Description string + Action Action + Mutating bool + ParallelSafe bool + Tags []string + Tier ToolTier +} + +// NewTyped builds a Tool from a typed handler function, deriving the +// Parameters JSON schema from the exported fields of Args via reflection. +// +// Schema rules: +// - Property names come from the `json` tag (fields tagged "-" or +// unexported fields are skipped; untagged fields use the Go name). +// - A field is optional when its json tag has ",omitempty" or its type is +// a pointer; all other fields are listed in "required". +// - Types map as: string→string, bool→boolean, ints/uints→integer, +// floats→number, slices/arrays→array (with "items"), maps and nested +// structs→object, interface{}→unconstrained. +// - Optional tags refine a property: `desc:"..."` sets "description", +// `min:"n"`/`max:"n"` set "minimum"/"maximum", and `enum:"a,b"` sets a +// comma-separated string "enum". +// +// The wrapper decodes the raw arguments into Args before calling fn (empty +// or null input yields the zero value, matching hand-written handlers that +// tolerate absent arguments). fn's result is returned verbatim when it is a +// string or json.RawMessage, and JSON-marshalled otherwise. +func NewTyped[Args any](spec TypedSpec, fn func(ctx context.Context, args Args) (any, error)) (Tool, error) { + if spec.Name == "" { + return Tool{}, fmt.Errorf("typed tool requires a name") + } + if fn == nil { + return Tool{}, fmt.Errorf("typed tool %q requires a handler function", spec.Name) + } + params, err := schemaForArgs(reflect.TypeOf((*Args)(nil)).Elem()) + if err != nil { + return Tool{}, fmt.Errorf("typed tool %q: %w", spec.Name, err) + } + + def := Definition{ + Name: spec.Name, + Description: spec.Description, + Parameters: params, + Action: spec.Action, + Mutating: spec.Mutating, + ParallelSafe: spec.ParallelSafe, + Tags: spec.Tags, + Tier: spec.Tier, + } + + handler := func(ctx context.Context, raw json.RawMessage) (string, error) { + var args Args + if trimmed := bytes.TrimSpace(raw); len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) { + if err := json.Unmarshal(trimmed, &args); err != nil { + return "", fmt.Errorf("parse %s args: %w", spec.Name, err) + } + } + out, err := fn(ctx, args) + if err != nil { + return "", err + } + switch v := out.(type) { + case string: + return v, nil + case json.RawMessage: + return string(v), nil + default: + return MarshalToolResult(out) + } + } + + return Tool{Definition: def, Handler: handler}, nil +} + +// MustTyped is NewTyped for statically known tools: schema derivation can +// only fail on an invalid Args type or empty spec, both of which are +// programmer errors, so it panics instead of returning an error. +func MustTyped[Args any](spec TypedSpec, fn func(ctx context.Context, args Args) (any, error)) Tool { + tool, err := NewTyped(spec, fn) + if err != nil { + panic(err) + } + return tool +} + +func schemaForArgs(t reflect.Type) (map[string]any, error) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("args type must be a struct, got %s", t.Kind()) + } + + props := map[string]any{} + var required []string + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + name, omitempty := parseJSONFieldTag(field) + if name == "-" { + continue + } + prop, err := schemaForType(field.Type) + if err != nil { + return nil, fmt.Errorf("field %s: %w", field.Name, err) + } + if desc := field.Tag.Get("desc"); desc != "" { + prop["description"] = desc + } + if minTag := field.Tag.Get("min"); minTag != "" { + v, err := parseSchemaNumber(minTag) + if err != nil { + return nil, fmt.Errorf("field %s: invalid min tag %q", field.Name, minTag) + } + prop["minimum"] = v + } + if maxTag := field.Tag.Get("max"); maxTag != "" { + v, err := parseSchemaNumber(maxTag) + if err != nil { + return nil, fmt.Errorf("field %s: invalid max tag %q", field.Name, maxTag) + } + prop["maximum"] = v + } + if enum := field.Tag.Get("enum"); enum != "" { + prop["enum"] = strings.Split(enum, ",") + } + props[name] = prop + if !omitempty && field.Type.Kind() != reflect.Pointer { + required = append(required, name) + } + } + + schema := map[string]any{ + "type": "object", + "properties": props, + } + if len(required) > 0 { + schema["required"] = required + } + return schema, nil +} + +func schemaForType(t reflect.Type) (map[string]any, error) { + switch t.Kind() { + case reflect.Pointer: + return schemaForType(t.Elem()) + case reflect.String: + return map[string]any{"type": "string"}, nil + case reflect.Bool: + return map[string]any{"type": "boolean"}, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return map[string]any{"type": "integer"}, nil + case reflect.Float32, reflect.Float64: + return map[string]any{"type": "number"}, nil + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + // []byte round-trips through JSON as a base64 string. + return map[string]any{"type": "string"}, nil + } + items, err := schemaForType(t.Elem()) + if err != nil { + return nil, err + } + return map[string]any{"type": "array", "items": items}, nil + case reflect.Map: + return map[string]any{"type": "object"}, nil + case reflect.Struct: + return schemaForArgs(t) + case reflect.Interface: + return map[string]any{}, nil + default: + return nil, fmt.Errorf("unsupported schema type %s", t.Kind()) + } +} + +func parseJSONFieldTag(field reflect.StructField) (name string, omitempty bool) { + tag := field.Tag.Get("json") + if tag == "" { + return field.Name, false + } + parts := strings.Split(tag, ",") + name = parts[0] + if name == "" { + name = field.Name + } + for _, opt := range parts[1:] { + if opt == "omitempty" { + omitempty = true + } + } + return name, omitempty +} + +// parseSchemaNumber keeps integer bounds as ints so generated schemas match +// hand-written literals like `"minimum": 1` byte-for-byte after marshalling. +func parseSchemaNumber(s string) (any, error) { + if n, err := strconv.Atoi(s); err == nil { + return n, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, err + } + return f, nil +} diff --git a/internal/harness/tools/typed_parity_test.go b/internal/harness/tools/typed_parity_test.go new file mode 100644 index 00000000..34f4a269 --- /dev/null +++ b/internal/harness/tools/typed_parity_test.go @@ -0,0 +1,139 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The migrated tools must expose exactly the schema their hand-written +// predecessors declared: the expected literals below are copied verbatim +// from the pre-migration Parameters maps. + +func TestGlobToolSchemaParity(t *testing.T) { + tool := globTool(t.TempDir(), SandboxScopeWorkspace) + assertSchemaEqual(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{"type": "string", "description": "glob pattern relative to workspace"}, + "max_matches": map[string]any{"type": "integer", "minimum": 1, "maximum": 2000}, + }, + "required": []string{"pattern"}, + }, tool.Definition.Parameters) + + def := tool.Definition + if def.Name != "glob" || def.Action != ActionList || !def.ParallelSafe || def.Mutating { + t.Fatalf("glob metadata changed: %+v", def) + } + if !reflect.DeepEqual(def.Tags, []string{"glob", "find", "files", "pattern", "names", "wildcard"}) { + t.Fatalf("glob tags changed: %v", def.Tags) + } +} + +func TestGitStatusToolSchemaParity(t *testing.T) { + tool := gitStatusTool(t.TempDir()) + assertSchemaEqual(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "porcelain": map[string]any{"type": "boolean"}, + }, + }, tool.Definition.Parameters) + + def := tool.Definition + if def.Name != "git_status" || def.Action != ActionRead || !def.ParallelSafe || def.Mutating { + t.Fatalf("git_status metadata changed: %+v", def) + } + if !reflect.DeepEqual(def.Tags, []string{"git", "status", "repository", "staged", "modified"}) { + t.Fatalf("git_status tags changed: %v", def.Tags) + } +} + +func TestGlobToolBehavior(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"a.go", "b.go", "c.txt"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + tool := globTool(root, SandboxScopeWorkspace) + + out, err := tool.Handler(context.Background(), json.RawMessage(`{"pattern":"*.go"}`)) + if err != nil { + t.Fatalf("handler: %v", err) + } + var result struct { + Pattern string `json:"pattern"` + Matches []string `json:"matches"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Pattern != "*.go" || !reflect.DeepEqual(result.Matches, []string{"a.go", "b.go"}) { + t.Fatalf("unexpected result: %+v", result) + } + + if _, err := tool.Handler(context.Background(), json.RawMessage(`{}`)); err == nil || + !strings.Contains(err.Error(), "pattern is required") { + t.Fatalf("expected pattern-required error, got %v", err) + } + + if _, err := tool.Handler(context.Background(), json.RawMessage(`{"max_matches":"many"}`)); err == nil || + !strings.Contains(err.Error(), "parse glob args") { + t.Fatalf("expected parse error, got %v", err) + } +} + +func TestGitStatusToolBehavior(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", root}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init") + if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + tool := gitStatusTool(root) + + // Absent args default to porcelain output, matching the pre-migration handler. + out, err := tool.Handler(context.Background(), nil) + if err != nil { + t.Fatalf("handler: %v", err) + } + var result struct { + Clean bool `json:"clean"` + Output string `json:"output"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Clean || !strings.Contains(result.Output, "?? f.txt") { + t.Fatalf("expected porcelain untracked entry, got %+v", result) + } + + // Explicit porcelain=false switches to the human-readable format. + out, err = tool.Handler(context.Background(), json.RawMessage(`{"porcelain":false}`)) + if err != nil { + t.Fatalf("handler: %v", err) + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if !strings.Contains(result.Output, "Untracked files") { + t.Fatalf("expected human-readable status, got %+v", result) + } +} diff --git a/internal/harness/tools/typed_test.go b/internal/harness/tools/typed_test.go new file mode 100644 index 00000000..224a2ac9 --- /dev/null +++ b/internal/harness/tools/typed_test.go @@ -0,0 +1,206 @@ +package tools + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func mustJSON(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(data) +} + +// assertSchemaEqual compares schemas by canonical JSON so map ordering and +// int-vs-float representation cannot cause false failures. +func assertSchemaEqual(t *testing.T, want, got map[string]any) { + t.Helper() + wantJSON, gotJSON := mustJSON(t, want), mustJSON(t, got) + if wantJSON != gotJSON { + t.Fatalf("schema mismatch:\nwant %s\ngot %s", wantJSON, gotJSON) + } +} + +func TestNewTypedDerivesSchema(t *testing.T) { + type args struct { + Pattern string `json:"pattern" desc:"glob pattern"` + MaxMatches int `json:"max_matches,omitempty" min:"1" max:"2000"` + Ratio float64 `json:"ratio,omitempty" min:"0.5"` + Verbose *bool `json:"verbose"` + Mode string `json:"mode,omitempty" enum:"fast,slow"` + Names []string `json:"names,omitempty"` + hidden string //nolint:unused // exercises the unexported-field skip + Skipped string `json:"-"` + } + + tool, err := NewTyped(TypedSpec{Name: "demo", Description: "d"}, func(ctx context.Context, a args) (any, error) { + return "ok", nil + }) + if err != nil { + t.Fatalf("NewTyped: %v", err) + } + + assertSchemaEqual(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{"type": "string", "description": "glob pattern"}, + "max_matches": map[string]any{"type": "integer", "minimum": 1, "maximum": 2000}, + "ratio": map[string]any{"type": "number", "minimum": 0.5}, + "verbose": map[string]any{"type": "boolean"}, + "mode": map[string]any{"type": "string", "enum": []string{"fast", "slow"}}, + "names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + "required": []string{"pattern"}, + }, tool.Definition.Parameters) +} + +func TestNewTypedNestedAndCompositeTypes(t *testing.T) { + type inner struct { + Depth int `json:"depth"` + } + type args struct { + Nested inner `json:"nested"` + Lookup map[string]int `json:"lookup,omitempty"` + Payload any `json:"payload,omitempty"` + Blob []byte `json:"blob,omitempty"` + } + + tool, err := NewTyped(TypedSpec{Name: "demo"}, func(ctx context.Context, a args) (any, error) { return "", nil }) + if err != nil { + t.Fatalf("NewTyped: %v", err) + } + + assertSchemaEqual(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "nested": map[string]any{ + "type": "object", + "properties": map[string]any{"depth": map[string]any{"type": "integer"}}, + "required": []string{"depth"}, + }, + "lookup": map[string]any{"type": "object"}, + "payload": map[string]any{}, + "blob": map[string]any{"type": "string"}, + }, + "required": []string{"nested"}, + }, tool.Definition.Parameters) +} + +func TestNewTypedSpecMetadataFlowsToDefinition(t *testing.T) { + type args struct{} + tool, err := NewTyped(TypedSpec{ + Name: "meta", + Description: "desc", + Action: ActionWrite, + Mutating: true, + ParallelSafe: true, + Tags: []string{"a", "b"}, + Tier: TierDeferred, + }, func(ctx context.Context, a args) (any, error) { return "", nil }) + if err != nil { + t.Fatalf("NewTyped: %v", err) + } + def := tool.Definition + if def.Name != "meta" || def.Description != "desc" || def.Action != ActionWrite || + !def.Mutating || !def.ParallelSafe || def.Tier != TierDeferred || + !reflect.DeepEqual(def.Tags, []string{"a", "b"}) { + t.Fatalf("metadata not propagated: %+v", def) + } +} + +func TestNewTypedHandlerDecodesAndMarshals(t *testing.T) { + type args struct { + Name string `json:"name"` + Count int `json:"count,omitempty"` + } + tool := MustTyped(TypedSpec{Name: "echo"}, func(ctx context.Context, a args) (any, error) { + return map[string]any{"name": a.Name, "count": a.Count}, nil + }) + + out, err := tool.Handler(context.Background(), json.RawMessage(`{"name":"x","count":3}`)) + if err != nil { + t.Fatalf("handler: %v", err) + } + if out != `{"count":3,"name":"x"}` { + t.Fatalf("unexpected result: %s", out) + } +} + +func TestNewTypedHandlerToleratesEmptyAndNullArgs(t *testing.T) { + type args struct { + Porcelain *bool `json:"porcelain,omitempty"` + } + tool := MustTyped(TypedSpec{Name: "zero"}, func(ctx context.Context, a args) (any, error) { + if a.Porcelain != nil { + t.Fatalf("expected zero-value args, got %+v", a) + } + return "zero", nil + }) + + for _, raw := range []string{"", "null", " "} { + out, err := tool.Handler(context.Background(), json.RawMessage(raw)) + if err != nil { + t.Fatalf("handler(%q): %v", raw, err) + } + if out != "zero" { + t.Fatalf("handler(%q) = %q", raw, out) + } + } +} + +func TestNewTypedHandlerStringAndRawPassthrough(t *testing.T) { + type args struct{} + str := MustTyped(TypedSpec{Name: "s"}, func(ctx context.Context, a args) (any, error) { + return "plain text", nil + }) + out, err := str.Handler(context.Background(), nil) + if err != nil || out != "plain text" { + t.Fatalf("string passthrough: %q, %v", out, err) + } + + raw := MustTyped(TypedSpec{Name: "r"}, func(ctx context.Context, a args) (any, error) { + return json.RawMessage(`{"already":"encoded"}`), nil + }) + out, err = raw.Handler(context.Background(), nil) + if err != nil || out != `{"already":"encoded"}` { + t.Fatalf("raw passthrough: %q, %v", out, err) + } +} + +func TestNewTypedHandlerRejectsMalformedArgs(t *testing.T) { + type args struct { + Name string `json:"name"` + } + tool := MustTyped(TypedSpec{Name: "strict"}, func(ctx context.Context, a args) (any, error) { + return "", nil + }) + _, err := tool.Handler(context.Background(), json.RawMessage(`{"name":42}`)) + if err == nil || !strings.Contains(err.Error(), "parse strict args") { + t.Fatalf("expected parse error naming the tool, got %v", err) + } +} + +func TestNewTypedRejectsInvalidInputs(t *testing.T) { + type args struct{} + if _, err := NewTyped(TypedSpec{}, func(ctx context.Context, a args) (any, error) { return "", nil }); err == nil { + t.Fatal("expected error for missing name") + } + if _, err := NewTyped[args](TypedSpec{Name: "x"}, nil); err == nil { + t.Fatal("expected error for nil handler") + } + if _, err := NewTyped(TypedSpec{Name: "x"}, func(ctx context.Context, a string) (any, error) { return "", nil }); err == nil { + t.Fatal("expected error for non-struct args") + } + type bad struct { + Ch chan int `json:"ch"` + } + if _, err := NewTyped(TypedSpec{Name: "x"}, func(ctx context.Context, a bad) (any, error) { return "", nil }); err == nil { + t.Fatal("expected error for unsupported field type") + } +}