From acec822d5918829337fb04fdfc01bdaf56a5b53d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 15:57:39 -0500 Subject: [PATCH 1/7] A client can attach tools to a conversation over the app wire agents.tools_set attaches, replaces or removes one named set of tools on a conversation; agents.tool_call carries each model call to the client and agents.tool_result answers it. The hello says agents.client_tools. Co-Authored-By: Claude Opus 5.5 --- docs/design/app-v3/PROTOCOL.md | 75 +++++++++++ internal/appwire/client_tools.go | 160 +++++++++++++++++++++++ internal/appwire/client_tools_test.go | 177 ++++++++++++++++++++++++++ internal/appwire/events.go | 1 + internal/appwire/server.go | 5 +- 5 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 internal/appwire/client_tools.go create mode 100644 internal/appwire/client_tools_test.go diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 68723d0488..bdd16a7a14 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -79,6 +79,81 @@ decides which combinations are accepted. | `agents.stop` | `in.IDParams` | `Empty` | | `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` | +## Client tools + +The optional feature `agents.client_tools` appears in `hello.features` exactly +when the engine implements the client-tools interface. Sets belong to this +stdio connection and one conversation. They are memory-only: register again +after reconnecting. Attachment does not open the conversation or start a turn. +Children, workers and clarification conversations inherit neither tools nor +instructions. Remote forwarding is unavailable. + +| method | params | result | +|---|---|---| +| `agents.tools_set` | `{agent, set, instructions?, deadline_ms?, tools?: [{name, description, input_schema, category}]}` | `{agent, set, tools: [name, ...]}` | +| `agents.tool_result` | `{call, result: }` or `{call, error: }` | `{accepted: true}` or `{accepted: false}` | + +`agents.tools_set` replaces that `agent` + `set` whole; other sets remain. +Omitted `tools` or `tools: []` removes only that set and returns `tools: []`. +An agent holds at most 4 sets, 32 tools per set and 64 tools total. All validation +is atomic: a refusal leaves every set unchanged. + +- `set` matches `^[a-z][a-z0-9_.-]{0,63}$`. +- `name` matches `^[a-z][a-z0-9_]{0,63}$`, unique within and across the agent's sets. +- `description` is 1–2048 bytes. `input_schema` is a JSON object whose `type` + is `"object"`, at most 16 KiB. Schema validation of call arguments is the app's job. +- `category` is `read`, `edit` or `create` and supplies this conversation's tool + category. Reading-only contexts refuse edits and creations. Ordinary tool + approval rules apply; attachment is not approval. +- `instructions` is at most 4096 bytes per set and may be empty. It appears in a + section headed `Tools from the app: `. A caller-supplied system page is + preserved and receives no section. Ordinary conversations gain no text. +- `deadline_ms` is 0 (the default 30000) or 1000–120000. + +Invalid fields, aggregate limits, duplicates and built-in tool names answer +`bad_params`; a collision message names the tool. Collision checks include the +live belt and all classified engine tools. A built-in arriving later wins and +the colliding client tool leaves the belt. No model request has duplicate names. +Unknown agents answer `not_found`. A remote-held conversation answers +`bad_scope`: `client tools need a conversation this engine runs`. +Tools and instructions update at the next step boundary, with client tools at +the tail after all built-ins. + +| event | data | +|---|---| +| `agents.tool_call` | `{agent, turn, call, turn_call, set, name, args: , deadline_ms}` | + +One model call emits exactly one `agents.tool_call`. `turn` is the engine's +current conversation turn; `turn_call` matches `turn.tool.call`. `call` is unique +among pending calls and deterministic for the agent + provider call id. Without +a provider id the engine mints a fresh id. A concurrent redispatch joins the +pending wait and emits no second event. A settled redispatch returns the saved +answer without an event for 10 minutes, subject to a maximum of 1024 saved +answers per connection. Non-object model arguments become an invalid-arguments +error result without an event. + +`agents.tool_result` requires exactly one of `result` and `error`. `result` +accepts any JSON, including `null`, and reaches the model as compact JSON text, +cut at 64 KiB with a plain dropped-byte marker. `error` must be a nonempty string +of at most 4096 bytes and becomes an error tool result: +` could not do : `. Invalid answers are `bad_params`. +A pending answer returns `accepted: true`; unknown, late or duplicate ids return +`accepted: false` without an error or any other effect. + +Connection end (EOF, shutdown or context cancellation) disconnects the registry +before conversations close: all sets are dropped and all pending calls settle +immediately. These failures reach the model as error tool results, with no +engine retry: + +- A stale call after removal or disconnection: + `client_unavailable: the app that provides is not connected; nothing was done.` +- Disconnection during a call: + `client_unavailable: the app that provides disconnected before answering; the outcome is unknown, so check before trying again.` +- Deadline: + `client_timeout: the app that provides did not answer within s; the outcome is unknown, so check before trying again.` +- Turn cancellation: the turn ended before the app answered. The pending entry + is dropped immediately and later answers return `accepted: false`. + ## asks methods | method | params | result | diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go new file mode 100644 index 0000000000..589f22fd07 --- /dev/null +++ b/internal/appwire/client_tools.go @@ -0,0 +1,160 @@ +package appwire + +import ( + "encoding/json" + "regexp" + + "github.com/Agent-Field/codeaf/internal/session" +) + +const FeatureClientTools = "agents.client_tools" +const EventAgentsToolCall = "agents.tool_call" + +// ClientTooler is optional because only a connected app can answer these tools. +type ClientTooler interface { + SetClientTools(ClientToolsSetParams) (ClientToolsSetResult, error) + ClientToolResult(ClientToolResultParams) (ClientToolResultAck, error) +} + +type ClientToolsSetParams struct { + Agent string `json:"agent"` + Set string `json:"set"` + Instructions string `json:"instructions"` + DeadlineMS int `json:"deadline_ms"` + Tools []session.ClientTool `json:"tools"` +} + +type ClientToolsSetResult struct { + Agent string `json:"agent"` + Set string `json:"set"` + Tools []string `json:"tools"` +} + +type AgentsToolCall struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + TurnCall string `json:"turn_call"` + Set string `json:"set"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` +} + +type ClientToolResultParams struct { + Call string `json:"call"` + // RawMessage distinguishes an explicit JSON null result from an omitted one. + Result json.RawMessage `json:"result,omitempty"` + Error *string `json:"error,omitempty"` +} + +type ClientToolResultAck struct { + Accepted bool `json:"accepted"` +} + +func (p *ClientToolResultParams) UnmarshalJSON(data []byte) error { + type plain ClientToolResultParams + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if _, present := fields["error"]; present && decoded.Error == nil { + return Errorf(CodeBadParams, "error must be a nonempty string") + } + *p = ClientToolResultParams(decoded) + return nil +} + +var clientToolSetName = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) +var clientToolName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// Validate rejects a whole replacement before the backend can change any set. +func (p ClientToolsSetParams) Validate() error { + if p.Agent == "" || !clientToolSetName.MatchString(p.Set) { + return Errorf(CodeBadParams, "agent and a valid set id are required") + } + if len(p.Tools) > session.ClientToolMaxPerSet || len(p.Instructions) > session.ClientToolInstructionsMax { + return Errorf(CodeBadParams, "a set allows at most %d tools and %d instruction bytes", session.ClientToolMaxPerSet, session.ClientToolInstructionsMax) + } + if p.DeadlineMS != 0 && (p.DeadlineMS < session.ClientToolMinDeadlineMS || p.DeadlineMS > session.ClientToolMaxDeadlineMS) { + return Errorf(CodeBadParams, "deadline_ms must be 0 or %d–%d", session.ClientToolMinDeadlineMS, session.ClientToolMaxDeadlineMS) + } + held := make(map[string]bool, len(p.Tools)) + for _, tool := range p.Tools { + if !clientToolName.MatchString(tool.Name) || held[tool.Name] { + return Errorf(CodeBadParams, "invalid or duplicate tool name %q", tool.Name) + } + held[tool.Name] = true + if err := validateClientTool(tool); err != nil { + return err + } + } + return nil +} + +func validateClientTool(tool session.ClientTool) error { + if len(tool.Description) == 0 || len(tool.Description) > session.ClientToolDescriptionMax { + return Errorf(CodeBadParams, "%s description must be 1–%d bytes", tool.Name, session.ClientToolDescriptionMax) + } + var object map[string]json.RawMessage + if len(tool.InputSchema) > session.ClientToolSchemaMax || json.Unmarshal(tool.InputSchema, &object) != nil || object == nil { + return Errorf(CodeBadParams, "%s input_schema must be an object of at most %d bytes", tool.Name, session.ClientToolSchemaMax) + } + var kind string + if json.Unmarshal(object["type"], &kind) != nil || kind != "object" { + return Errorf(CodeBadParams, "%s input_schema type must be object", tool.Name) + } + switch tool.Category { + case session.ActionRead, session.ActionEdit, session.ActionCreate: + return nil + } + return Errorf(CodeBadParams, "%s category must be read, edit or create", tool.Name) +} + +func (p ClientToolResultParams) Validate() error { + if (p.Result != nil) == (p.Error != nil) { + return Errorf(CodeBadParams, "exactly one of result or error is required") + } + if p.Error != nil && (len(*p.Error) == 0 || len(*p.Error) > session.ClientToolErrorMax) { + return Errorf(CodeBadParams, "error must be 1–%d bytes", session.ClientToolErrorMax) + } + if p.Result != nil && !json.Valid(p.Result) { + return Errorf(CodeBadParams, "result must be JSON") + } + return nil +} + +func withClientToolsFeature(hello Hello, engine Engine) Hello { + if _, ok := engine.(ClientTooler); ok { + return withFeature(hello, FeatureClientTools) + } + return hello +} + +func setClientTools(e Engine, p ClientToolsSetParams) (*ClientToolsSetResult, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.SetClientTools(p) + return &result, err +} + +func answerClientTool(e Engine, p ClientToolResultParams) (*ClientToolResultAck, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.ClientToolResult(p) + return &result, err +} diff --git a/internal/appwire/client_tools_test.go b/internal/appwire/client_tools_test.go new file mode 100644 index 0000000000..d01bed6a61 --- /dev/null +++ b/internal/appwire/client_tools_test.go @@ -0,0 +1,177 @@ +package appwire + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolsEngine struct { + sendEngine + sets []ClientToolsSetParams + answers []ClientToolResultParams +} + +func (e *clientToolsEngine) SetClientTools(p ClientToolsSetParams) (ClientToolsSetResult, error) { + e.sets = append(e.sets, p) + names := []string{} + for _, tool := range p.Tools { + names = append(names, tool.Name) + } + return ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: names}, nil +} +func (e *clientToolsEngine) ClientToolResult(p ClientToolResultParams) (ClientToolResultAck, error) { + e.answers = append(e.answers, p) + return ClientToolResultAck{Accepted: true}, nil +} + +func validClientToolsParams() ClientToolsSetParams { + return ClientToolsSetParams{Agent: "agent-1", Set: "app.notes", Tools: []session.ClientTool{{Name: "app_note", Description: "Read a note", + InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionRead}}} +} + +// Contract (f): the actual Serve hello advertises only an implemented door. +func TestClientToolsHelloFeature(t *testing.T) { + for _, test := range []struct { + engine Engine + want bool + }{{&sendEngine{}, false}, {&clientToolsEngine{}, true}} { + var out bytes.Buffer + server := NewServer(&out) + if err := server.Serve(context.Background(), strings.NewReader(""), Hello{}, test.engine); err != nil { + t.Fatal(err) + } + var frame struct { + Data Hello `json:"data"` + } + if err := json.Unmarshal(bytes.Split(out.Bytes(), []byte("\n"))[0], &frame); err != nil { + t.Fatal(err) + } + found := false + for _, feature := range frame.Data.Features { + found = found || feature == FeatureClientTools + } + if found != test.want { + t.Fatalf("features=%v want client tools=%v", frame.Data.Features, test.want) + } + } +} + +// Contract (b), (h): both typed methods preserve their declared shapes. +func TestClientToolsTypedMethods(t *testing.T) { + e := &clientToolsEngine{} + raw, _ := json.Marshal(validClientToolsParams()) + result, err := methods["agents.tools_set"].call(e, raw) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"agent":"agent-1","set":"app.notes","tools":["app_note"]}` { + t.Fatal(string(encoded)) + } + for _, raw := range []string{`{"call":"ct_1","result":null}`, `{"call":"ct_1","result":[1,true]}`, `{"call":"ct_1","error":"no access"}`} { + result, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"accepted":true}` { + t.Fatal(string(encoded)) + } + } + if len(e.sets) != 1 || len(e.answers) != 3 || string(e.answers[0].Result) != "null" { + t.Fatal("typed arguments were lost") + } + if _, err := methods["agents.tools_set"].call(&sendEngine{}, raw); codeOf(err) != CodeUnknownMethod { + t.Fatal(err) + } +} + +// Contract (h): every scalar and per-set limit refuses before the optional engine. +func TestClientToolsValidationRefusals(t *testing.T) { + changes := map[string]func(*ClientToolsSetParams){ + "agent": func(p *ClientToolsSetParams) { p.Agent = "" }, + "set": func(p *ClientToolsSetParams) { p.Set = "Bad set" }, + "set length": func(p *ClientToolsSetParams) { p.Set = strings.Repeat("a", 65) }, + "name": func(p *ClientToolsSetParams) { p.Tools[0].Name = "Bad-name" }, + "name length": func(p *ClientToolsSetParams) { p.Tools[0].Name = strings.Repeat("a", 65) }, + "duplicate": func(p *ClientToolsSetParams) { p.Tools = append(p.Tools, p.Tools[0]) }, + "description empty": func(p *ClientToolsSetParams) { p.Tools[0].Description = "" }, + "description bytes": func(p *ClientToolsSetParams) { + p.Tools[0].Description = strings.Repeat("é", session.ClientToolDescriptionMax) + }, + "schema array": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`[]`) }, + "schema null": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`null`) }, + "schema type": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`{"type":"array"}`) }, + "schema bytes": func(p *ClientToolsSetParams) { + p.Tools[0].InputSchema = json.RawMessage(`{"type":"object","padding":"` + strings.Repeat("x", session.ClientToolSchemaMax) + `"}`) + }, + "category": func(p *ClientToolsSetParams) { p.Tools[0].Category = session.ActionRun }, + "tools": func(p *ClientToolsSetParams) { p.Tools = make([]session.ClientTool, session.ClientToolMaxPerSet+1) }, + "instructions": func(p *ClientToolsSetParams) { + p.Instructions = strings.Repeat("x", session.ClientToolInstructionsMax+1) + }, + "negative deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = -1 }, + "short deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMinDeadlineMS - 1 }, + "long deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMaxDeadlineMS + 1 }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + p := validClientToolsParams() + change(&p) + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + e := &clientToolsEngine{} + if _, err := methods["agents.tools_set"].call(e, raw); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %s: %v", name, err) + } + if len(e.sets) != 0 { + t.Fatal("invalid set reached the engine") + } + }) + } +} + +// Contract (h): null is a result, while an error must be a nonempty string. +func TestClientToolResultValidation(t *testing.T) { + for _, raw := range []string{`{}`, `{"call":"c","result":1,"error":"bad"}`, `{"call":"c","error":""}`, `{"call":"c","error":null}`, + `{"call":"c","result":1,"error":null}`, `{"call":"c","error":7}`, `{"call":"c","error":"` + strings.Repeat("x", session.ClientToolErrorMax+1) + `"}`} { + e := &clientToolsEngine{} + if _, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %.90s: %v", raw, err) + } + if len(e.answers) > 0 { + t.Fatal("invalid answer reached the engine") + } + } +} + +// Contract (h): the shipped section stays bounded and names the actual limits. +func TestClientToolsManualLimits(t *testing.T) { + raw, err := os.ReadFile("../manual/chat/client-tools.md") + if err != nil { + t.Fatal(err) + } + page := string(raw) + if strings.Count(page, "\n## ") != 1 || len(raw) >= 2000 { + t.Fatal("client tools manual section is not self-contained and bounded") + } + for _, phrase := range []string{ + fmt.Sprintf("%d sets", session.ClientToolMaxSets), fmt.Sprintf("%d tools per set", session.ClientToolMaxPerSet), + fmt.Sprintf("%d tools total", session.ClientToolMaxPerAgent), fmt.Sprintf("%d seconds", session.ClientToolDefaultDeadlineMS/1000), + fmt.Sprintf("%d seconds is the maximum", session.ClientToolMaxDeadlineMS/1000), fmt.Sprintf("%d KiB", session.ClientToolOutputMax/1024), + "outcome unknown", "Child tasks", "connected", + } { + if !strings.Contains(page, phrase) { + t.Errorf("manual omits %q", phrase) + } + } +} diff --git a/internal/appwire/events.go b/internal/appwire/events.go index 84ebe55db1..84388ab23c 100644 --- a/internal/appwire/events.go +++ b/internal/appwire/events.go @@ -6,6 +6,7 @@ import "reflect" // other type is a contract break; the test recorders check every event they // see against this table, and the schema is generated from it. var eventShapes = map[string]reflect.Type{ + EventAgentsToolCall: reflect.TypeFor[AgentsToolCall](), EventPairChanged: reflect.TypeFor[PairResult](), EventChatChanged: reflect.TypeFor[ChatChanged](), EventChatRemoved: reflect.TypeFor[ChatRemoved](), diff --git a/internal/appwire/server.go b/internal/appwire/server.go index cab10176be..32669f483d 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -214,6 +214,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withReplySuggestionFeature(hello, engine) hello = withUnreadFeature(hello, engine) hello = withErrandFeature(hello, engine) + hello = withClientToolsFeature(hello, engine) hello = withAsksDrawnFeature(hello, engine) hello = withTaskModelsAuxiliaryFeature(hello, engine) hello = withMemorySettingsFeature(hello, engine) @@ -396,7 +397,9 @@ func (s *Server) handle(ctx context.Context, line []byte, engine Engine, infligh // methods is the dispatch table. shutdown is not in it: it is answered by the // loop itself, because it ends the loop. var methods = map[string]method{ - "ping": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), + "agents.tools_set": typed(setClientTools), + "agents.tool_result": typed(answerClientTool), + "ping": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), "agents.list": typed(func(e Engine, p AgentsListParams) (*AgentsResult, error) { // A STATE AND A QUERY are answered by an engine that keeps them. if t, ok := e.(Tidier); ok { From daeeadb0fd8de4a4d164d4b0527005ab7a6d35ae Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 15:57:43 -0500 Subject: [PATCH 2/7] Client tools ride at the tail of a conversation's belt A conversation with a client tool door offers the client's tools after every built-in, with each set's instructions on its own page and nothing added when no set is attached. Tools follow their declared category, a built-in name wins, and child tasks and discussions get neither tools nor instructions. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/client-tools.md | 33 ++ internal/session/agent.go | 2 + internal/session/bashbelt_worker.go | 1 + internal/session/client_tools.go | 331 ++++++++++++++++++++ internal/session/client_tools_test.go | 380 +++++++++++++++++++++++ internal/session/connect.go | 19 +- internal/session/inherit.go | 1 + internal/session/loop.go | 7 +- internal/session/prompt.go | 1 + internal/session/promptprofile_live.go | 6 + internal/session/questionconversation.go | 2 + internal/session/session.go | 20 +- internal/session/task_app_steps.go | 2 +- internal/session/task_quick_files.go | 3 + internal/session/team.go | 1 + internal/session/tools.go | 2 +- 16 files changed, 792 insertions(+), 19 deletions(-) create mode 100644 internal/manual/chat/client-tools.md create mode 100644 internal/session/client_tools.go create mode 100644 internal/session/client_tools_test.go diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md new file mode 100644 index 0000000000..50a25c3b0e --- /dev/null +++ b/internal/manual/chat/client-tools.md @@ -0,0 +1,33 @@ +# Tools an app adds to a conversation + +## Tools an app adds to a conversation: connection, limits and waiting + +An app connected through `codeaf app` can attach its own tools and instructions +to one conversation. The model sees each tool's name, description and input +schema, and codeaf sends its calls to that app for an answer. The app checks the +arguments against its schema. Child tasks, workers and clarification +conversations do not receive these tools or instructions. + +Tools appear only while the app is connected. Attaching them does not open the +conversation or start a turn. Changes take effect at the next model step. +Each conversation can hold 4 sets, with 32 tools per set and 64 tools total. +Replacing a set replaces all its tools and instructions; an empty set removes +only that set. Tool names must be unique within the conversation and cannot +reuse an engine tool's name. + +A conversation held by a collection is kept even before its first message; +it can be reopened by id after a restart. + +Set ids start with a lowercase letter, followed by lowercase letters, digits, +underscores, dots or hyphens, up to 64 characters. Tool names follow the same +rule without dots or hyphens. Descriptions take 1–2048 bytes; each input schema +is a JSON object with `type: "object"`, up to 16 KiB. Instructions take up to +4096 bytes per set. Each tool declares `read`, `edit` or `create`; reading-only +contexts refuse edits and creations. The usual tool approval rules still apply. + +The default wait is 30 seconds. An app can choose 1–120 seconds per set; +120 seconds is the maximum. Stopping the turn ends the wait immediately. +A timeout or a disconnect during a call leaves its outcome unknown. Check what +the app did before trying again: codeaf does not retry that call automatically. +Results are compact JSON, cut at 64 KiB with the dropped-byte count. Error +answers take 1–4096 bytes. diff --git a/internal/session/agent.go b/internal/session/agent.go index d9ea313935..2acfd69468 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -90,6 +90,8 @@ func New(config Config) (*Agent, error) { // parent's account-keyed pool and resolve their own model before their first // request, rather than inheriting whichever adapter the parent last used. func (a *Agent) newChildAgent(config Config) (*Agent, error) { + config.ClientTools = nil + config.System = clientToolsWithoutSections(config.System) if pair, ok := a.ConversationModelPair(); ok && config.ConversationModels == nil { pair.Main = config.Model if config.OneModel || pair.Fallback == pair.Main { diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index e10c177e40..fa469f45f1 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -351,6 +351,7 @@ func (a *Agent) TaskReport() string { return taskReport(a) } // The shared event hub stays asynchronous; only this producer waits, outside // every agent and hub lock. Cancellation also releases a failed event reader. func (a *Agent) sendBeltStep(ctx context.Context, hub *eventHub, event Event) { + event = a.clientToolEvent(event) if !a.config.WaitForBeltSteps { hub.send(event) return diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go new file mode 100644 index 0000000000..55d4c2afbe --- /dev/null +++ b/internal/session/client_tools.go @@ -0,0 +1,331 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// Client tool limits are shared by the wire validator and the conversation door. +const ( + ClientToolMaxSets = 4 + ClientToolMaxPerSet = 32 + ClientToolMaxPerAgent = 64 + ClientToolDescriptionMax = 2048 + ClientToolSchemaMax = 16 << 10 + ClientToolInstructionsMax = 4096 + ClientToolErrorMax = 4096 + ClientToolOutputMax = 64 << 10 + ClientToolDefaultDeadlineMS = 30000 + ClientToolMinDeadlineMS = 1000 + ClientToolMaxDeadlineMS = 120000 +) + +// ClientToolDoor belongs to one conversation and one connected app. +type ClientToolDoor interface { + // Snapshots are immutable, so an unchanged version costs no copying. + ClientToolSets() (version uint64, sets []ClientToolSet) + CallClientTool(context.Context, ClientToolCall) (ClientToolAnswer, error) +} + +type ClientTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` + Category ActionCategory `json:"category"` +} + +type ClientToolSet struct { + ID string + Instructions string + DeadlineMS int + Tools []ClientTool +} + +type ClientToolCall struct { + Set, Name, TurnCall string + Args json.RawMessage + DeadlineMS int +} + +type ClientToolAnswer struct { + Text string + IsError bool +} + +// clientToolState is protected by armMu, along with the belt it describes. +type clientToolState struct { + version uint64 + seen bool + sets []ClientToolSet + names map[string]ActionCategory + retired map[string]bool + section string +} + +func (c Config) clientToolSets() (uint64, []ClientToolSet) { + if c.ClientTools == nil || c.InTask || c.Errand || c.promptProfile().chat() { + return 0, nil + } + return c.ClientTools.ClientToolSets() +} + +const clientToolHeading = "\n\n## Tools from the app: " +const clientToolSectionEnd = "\n" + +func clientToolsSection(config Config) string { + _, sets := config.clientToolSets() + return clientToolsSectionOf(sets) +} + +func clientToolsSectionOf(sets []ClientToolSet) string { + var out strings.Builder + for _, set := range sets { + out.WriteString(clientToolHeading) + out.WriteString(set.ID) + out.WriteByte('\n') + out.WriteString(set.Instructions) + out.WriteString(clientToolSectionEnd) + } + return out.String() +} + +// Child pages lose the app's instructions as well as the execution door. +func clientToolsWithoutSections(page string) string { + start := strings.Index(page, clientToolHeading) + if start < 0 { + return page + } + // All app sections form one tail. The last renderer-owned end also covers + // delimiter text that an app may have included inside its instructions. + end := strings.LastIndex(page, clientToolSectionEnd) + if end < start { + return page[:start] + } + return page[:start] + page[end+len(clientToolSectionEnd):] +} + +// appendClientTools runs during construction and complete belt rebuilds. +func (a *Agent) appendClientTools(builtins []bare.Tool) []bare.Tool { + a.armMu.Lock() + defer a.armMu.Unlock() + version, sets := a.config.clientToolSets() + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets = sets + a.clientTools.section = clientToolsSectionOf(sets) + return a.clientToolTailLocked(builtins, sets) +} + +func (a *Agent) clientToolTailLocked(builtins []bare.Tool, sets []ClientToolSet) []bare.Tool { + a.noteClientToolBuiltinsLocked(builtins) + for name := range a.clientTools.names { + if a.clientTools.retired == nil { + a.clientTools.retired = make(map[string]bool) + } + a.clientTools.retired[name] = true + } + a.clientTools.names = nil + if len(sets) == 0 { + return builtins + } + tools := make([]bare.Tool, len(builtins), len(builtins)+ClientToolMaxPerAgent) + copy(tools, builtins) + held := make(map[string]bool, len(builtins)) + for _, tool := range builtins { + held[tool.Name] = true + } + a.clientTools.names = make(map[string]ActionCategory) + for _, set := range sets { + for _, spec := range set.Tools { + if held[spec.Name] { + continue + } + held[spec.Name] = true + a.clientTools.names[spec.Name] = spec.Category + delete(a.clientTools.retired, spec.Name) + tools = append(tools, a.clientTool(set, spec)) + } + } + return tools +} + +func (a *Agent) noteClientToolBuiltinsLocked(builtins []bare.Tool) { + observer, ok := a.config.ClientTools.(interface{ ClientToolBuiltins([]string) }) + if !ok { + return + } + names := make([]string, 0, len(builtins)) + for _, tool := range builtins { + names = append(names, tool.Name) + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + observer.ClientToolBuiltins(names) +} + +func (a *Agent) clientToolRetiredNotice(name string) (string, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + if !a.clientTools.retired[name] { + return "", false + } + return ClientToolUnavailable(name).Text, true +} + +func ClientToolUnavailable(name string) ClientToolAnswer { + return ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s is not connected; nothing was done.", name), IsError: true} +} + +// BoundClientToolOutput reuses the engine's rune-safe cut and spells the loss plainly. +func BoundClientToolOutput(text string) string { + bounded := capBytes(text, ClientToolOutputMax) + if len(text) <= ClientToolOutputMax { + return bounded + } + cut := strings.LastIndex(bounded, "… (") + return bounded[:cut] + fmt.Sprintf("\n[cut: %d bytes dropped]", len(text)-cut) +} + +func (a *Agent) clientTool(set ClientToolSet, spec ClientTool) bare.Tool { + return bare.Tool{Name: spec.Name, Description: spec.Description, Schema: spec.InputSchema, + Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + var object map[string]json.RawMessage + data := bytes.TrimSpace(args) + if len(data) == 0 || data[0] != '{' || !json.Valid(data) || decodeToolArguments(data, &object) != nil || object == nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+"expected a JSON object"), true, nil + } + answer, err := a.config.ClientTools.CallClientTool(ctx, ClientToolCall{ + Set: set.ID, Name: spec.Name, Args: args, TurnCall: callIDFrom(ctx), DeadlineMS: set.DeadlineMS, + }) + if err != nil { + return err.Error(), true, nil + } + return answer.Text, answer.IsError, nil + }, + } +} + +// A boundary swaps fresh arrays; existing requests can keep walking their snapshot. +func (a *Agent) clientToolsBoundary() { + if a.config.ClientTools == nil { + return + } + version, sets := a.config.clientToolSets() + a.armMu.Lock() + if a.clientTools.seen && version == a.clientTools.version { + a.armMu.Unlock() + return + } + section := clientToolsSectionOf(sets) + changed := section != a.clientTools.section + builtins, definitions, err := a.clientToolBuiltinsLocked(0) + if err != nil { + a.armMu.Unlock() + return + } + a.tools = a.clientToolTailLocked(builtins, sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(definitions, tail...) + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets, a.clientTools.section = sets, section + a.armMu.Unlock() + if changed { + a.mu.Lock() + a.rerenderSystemLocked(time.Now()) + a.mu.Unlock() + } +} + +func (a *Agent) clientToolBuiltinsLocked(room int) ([]bare.Tool, []ai.ToolDefinition, error) { + cache := a.definitions + if len(cache) < len(a.tools) { + // Lightweight callers may have no cache, or only a previously armed family's definitions. + var err error + cache, err = toolDefinitions(a.tools) + if err != nil { + return nil, nil, err + } + } + tools := make([]bare.Tool, 0, len(a.tools)+room) + definitions := make([]ai.ToolDefinition, 0, len(a.tools)+room) + for i, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; client { + continue + } + tools = append(tools, tool) + definitions = append(definitions, cache[i]) + } + return tools, definitions, nil +} + +func (a *Agent) withoutClientToolsLocked(tools []bare.Tool) []bare.Tool { + if len(a.clientTools.names) == 0 { + return tools + } + builtins := make([]bare.Tool, 0, len(tools)) + for _, tool := range tools { + if _, client := a.clientTools.names[tool.Name]; !client { + builtins = append(builtins, tool) + } + } + return builtins +} + +// ActionCategoryForTool resolves this conversation's declaration before the static floor. +func (a *Agent) ActionCategoryForTool(name string) ActionCategory { + a.armMu.Lock() + category, ok := a.clientTools.names[name] + a.armMu.Unlock() + if ok { + return category + } + return ActionCategoryForTool(name) +} + +func (a *Agent) clientToolCategory(name string) (ActionCategory, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + category, ok := a.clientTools.names[name] + return category, ok +} + +func (a *Agent) clientToolEvent(event Event) Event { + if category, ok := a.clientToolCategory(event.Tool); ok { + event.Category = category + } + return event +} + +func toolEventCategory(event Event) ActionCategory { + if event.Category != "" { + return event.Category + } + return ActionCategoryForTool(event.Tool) +} + +// ClientToolBuiltinNames includes the live belt and deferred tools for collision checks. +func (a *Agent) ClientToolBuiltinNames() []string { + a.armMu.Lock() + defer a.armMu.Unlock() + var names []string + for _, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; !client { + names = append(names, tool.Name) + } + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + return names +} diff --git a/internal/session/client_tools_test.go b/internal/session/client_tools_test.go new file mode 100644 index 0000000000..6b4f7fdb52 --- /dev/null +++ b/internal/session/client_tools_test.go @@ -0,0 +1,380 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +type clientToolsFakeDoor struct { + version uint64 + sets []ClientToolSet + calls []ClientToolCall + answer ClientToolAnswer +} + +func (d *clientToolsFakeDoor) ClientToolSets() (uint64, []ClientToolSet) { return d.version, d.sets } +func (d *clientToolsFakeDoor) CallClientTool(_ context.Context, call ClientToolCall) (ClientToolAnswer, error) { + d.calls = append(d.calls, call) + return d.answer, nil +} + +func clientToolsFixture() *clientToolsFakeDoor { + return &clientToolsFakeDoor{version: 1, sets: []ClientToolSet{{ID: "notes", Instructions: "Use the app's notes.", DeadlineMS: ClientToolDefaultDeadlineMS, + Tools: []ClientTool{{Name: "app_note", Description: "Read a note", InputSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`), Category: ActionRead}}}}} +} + +// Contract (a), (e): definitions and instructions follow only this conversation's sets. +func TestClientToolsTailInstructionsAndRemoval(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, profile: profileFull}} + builtins := []bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}} + a.tools = a.appendClientTools(builtins) + a.definitions, _ = toolDefinitions(a.tools) + defs := a.beltDefinitions() + if len(defs) != 2 || a.tools[1].Name != "app_note" { + t.Fatalf("client tool is not the tail: %+v", a.tools) + } + encoded, _ := json.Marshal(defs[1]) + for _, word := range []string{"app_note", "Read a note", "properties", "id"} { + if !strings.Contains(string(encoded), word) { + t.Errorf("definition lost %q: %s", word, encoded) + } + } + page := renderSystem(a.config) + if !strings.Contains(page, "## Tools from the app: notes\nUse the app's notes.") { + t.Fatal("instructions absent") + } + if strings.Contains(renderSystem(Config{}), "Tools from the app") { + t.Fatal("ordinary prompt gained client bytes") + } + if got := testing.AllocsPerRun(10, a.clientToolsBoundary); got != 0 { + t.Fatalf("unchanged boundary allocated %v", got) + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if len(a.beltTools()) != 1 || len(defs) != 2 { + t.Fatal("removal changed an old reader or kept the tool") + } + if strings.Contains(renderSystem(a.config), "Tools from the app") { + t.Fatal("removed instructions remain") + } +} + +// Contract (b), (h): object arguments only, errors and provider ids cross the door. +func TestClientToolsExecuteArgumentsAndError(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, args := range []string{"null", "[]", "1", `"x"`, `"{\"id\":3}"`, "{"} { + text, failed, err := tool.Execute(context.Background(), json.RawMessage(args)) + if !failed || err != nil || !strings.Contains(text, "Invalid arguments") { + t.Fatalf("%s: %q %v %v", args, text, failed, err) + } + } + if len(door.calls) != 0 { + t.Fatal("non-object reached the client") + } + door.answer = ClientToolAnswer{Text: "notes could not do app_note: unavailable", IsError: true} + text, failed, err := tool.Execute(withCallID(context.Background(), "provider-7"), json.RawMessage(`{}`)) + if err != nil || !failed || text != door.answer.Text || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("answer/id lost: %q %+v", text, door.calls) + } +} + +// Contract (h): classification is per conversation and client tools never start early. +func TestClientToolsCategoriesAndBuiltinWins(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools(nil) + a.definitions, _ = toolDefinitions(a.tools) + if a.ActionCategoryForTool("app_note") != ActionRead || (&Agent{}).ActionCategoryForTool("app_note") != ActionWork { + t.Fatal("category escaped the conversation") + } + otherDoor := clientToolsFixture() + otherDoor.sets[0].Tools[0].Category = ActionCreate + other := &Agent{config: Config{ClientTools: otherDoor}} + other.tools = other.appendClientTools(nil) + if other.ActionCategoryForTool("app_note") != ActionCreate || a.ActionCategoryForTool("app_note") != ActionRead { + t.Fatal("two conversations shared a category declaration") + } + if earlyTools["app_note"] { + t.Fatal("client tool can start speculatively") + } + old := a.beltTools() + if _, err := a.armFamily([]bare.Tool{{Name: "app_note", Description: "Built-in", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + if len(a.tools) != 1 || a.tools[0].Description != "Built-in" || old[0].Description == "Built-in" { + t.Fatal("late built-in did not win with fresh arrays") + } +} + +// Contract (a), (h): newly armed built-ins keep their prefix ahead of the app tail. +func TestClientToolsLateBuiltinPrecedesTail(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools([]bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}}) + a.definitions, _ = toolDefinitions(a.tools) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + if _, err := a.armFamily([]bare.Tool{{Name: "new_builtin", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + tools := a.beltTools() + if len(tools) != 3 || tools[0].Name != "read" || tools[1].Name != "new_builtin" || tools[2].Name != "app_note" { + t.Fatalf("belt order: %+v", tools) + } + if len(oldTools) != 2 || oldTools[1].Name != "app_note" || len(oldDefinitions) != 2 { + t.Fatal("arming rewrote an old request") + } +} + +// Contract (a), (h): automation looks arm a family without a complete definition cache. +func TestClientToolsArmFamilyWithMissingDefinitions(t *testing.T) { + for _, withClient := range []bool{false, true} { + for _, cached := range []string{"nil", "short prefix", "short tail"} { + t.Run(fmt.Sprintf("client=%t/cache=%s", withClient, cached), func(t *testing.T) { + a := &Agent{} + a.tools = []bare.Tool{ + {Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}, + {Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}, + } + if withClient { + a.config.ClientTools = clientToolsFixture() + a.tools = a.appendClientTools(a.tools) + } + if cached != "nil" { + from := a.tools[:1] + if cached == "short tail" { + from = a.tools[len(a.tools)-1:] + } + var err error + a.definitions, err = toolDefinitions(from) + if err != nil { + t.Fatal(err) + } + } + cachedCount := len(a.definitions) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + names, err := a.armFamily([]bare.Tool{{Name: "account_read", Schema: json.RawMessage(`{"type":"object"}`)}}) + if err != nil || len(names) != 1 || names[0] != "account_read" { + t.Fatalf("arming: %v, %v", names, err) + } + want := []string{"read", "bash", "account_read"} + if withClient { + want = append(want, "app_note") + } + tools, definitions := a.beltTools(), a.beltDefinitions() + if len(tools) != len(want) || len(definitions) != len(want) { + t.Fatalf("belt/cache lengths: %d/%d, want %d", len(tools), len(definitions), len(want)) + } + for i, name := range want { + if tools[i].Name != name || definitions[i].Function.Name != name { + t.Fatalf("belt/cache at %d: %s/%s, want %s", i, tools[i].Name, definitions[i].Function.Name, name) + } + } + if len(oldDefinitions) != cachedCount || oldTools[1].Name != "bash" || (withClient && oldTools[2].Name != "app_note") { + t.Fatal("arming changed an earlier snapshot") + } + }) + } + } +} + +// Contract (a), (b): the ordinary provider loop offers definitions and consumes the answer. +func TestClientToolsModelRequestAndToolResult(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "result", true: "error"}[failed], func(t *testing.T) { + door := clientToolsFixture() + door.answer = ClientToolAnswer{Text: `{"note":"found"}`, IsError: failed} + if failed { + door.answer.Text = "notes could not do app_note: unavailable" + } + recorder := &blockRecorder{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("provider-7", "app_note", `{"id":"n"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + a, _ := newTestAgent(t, recorder, func(c *Config) { c.System = ""; c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + events, err := a.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + if len(door.calls) != 1 || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("dispatches: %+v", door.calls) + } + if got := recorder.resultAt(t, 1, "provider-7"); got != door.answer.Text { + t.Fatalf("model result: %q", got) + } + recorder.mu.Lock() + block := recorder.blocks[0] + page := messageText(recorder.sent[0][0]) + recorder.mu.Unlock() + definition, _ := json.Marshal(block[len(block)-1]) + if !strings.Contains(string(definition), "app_note") || !strings.Contains(page, "## Tools from the app: notes") { + t.Fatalf("request lost its tail/page: %s", definition) + } + ended := false + for _, event := range collected { + if event.CallID != "provider-7" { + continue + } + if event.Kind == EventToolBegin && toolEventCategory(event) != ActionRead { + t.Fatal("step lost the declared category") + } + if event.Kind == EventToolEnd || event.Kind == EventToolFailed { + ended = true + if (event.Kind == EventToolFailed) != failed { + t.Fatalf("error flag lost: %+v", event) + } + } + } + if !ended { + t.Fatal("tool result lifecycle absent") + } + }) + } +} + +// Contract (e): instructions rerender only on this agent's own page at a boundary. +func TestClientToolsBoundaryRefreshesOwnedPageAndPreservesCallerPage(t *testing.T) { + for _, supplied := range []string{"", "The caller's page"} { + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.System = supplied; c.ClientTools = door }) + door.version++ + door.sets = []ClientToolSet{{ID: "notes", Instructions: "Read the updated notes.", Tools: door.sets[0].Tools}} + a.clientToolsBoundary() + if supplied != "" && a.system != supplied { + t.Fatal("the caller's page was rewritten") + } + if supplied == "" && !strings.Contains(a.system, "Read the updated notes.") { + t.Fatal("instruction change did not rerender") + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if strings.Contains(a.system, "Tools from the app") { + t.Fatal("disconnected page kept instructions") + } + if notice, ok := a.clientToolRetiredNotice("app_note"); !ok || notice != ClientToolUnavailable("app_note").Text { + t.Fatal("stale call lost the unavailable answer") + } + } +} + +// Contract (h): declared reads alone can cross a reading-only context. +func TestClientToolsReadingCategoriesAndApprovalPolicy(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, taskControl: &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}}}} + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") != "" { + t.Fatal("declared read refused") + } + for _, category := range []ActionCategory{ActionEdit, ActionCreate} { + door.sets[0].Tools[0].Category = category + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") == "" { + t.Fatalf("reading allowed %s", category) + } + } + for _, action := range []approval.Action{approval.ActionAllow, approval.ActionPrompt, approval.ActionDeny} { + a.config.ApprovalPolicy = &approval.Policy{Default: action} + for _, name := range []string{"app_note", "factory_add", "change_setting"} { + decision, _ := a.decide(ai.ToolCall{Function: ai.ToolCallFunction{Name: name, Arguments: `{}`}}) + if decision.Action != action { + t.Fatalf("%s changed the ordinary policy: %+v", name, decision) + } + } + } +} + +// Contract (a), (h): real children and clarification conversations carry no client capability. +func TestClientToolsDoNotTravelToChildren(t *testing.T) { + door := clientToolsFixture() + door.sets[0].Instructions += clientToolSectionEnd + "\nThese are still the app's instructions." + page := "The caller's page" + clientToolsSection(Config{ClientTools: door}) + "\nFooter" + if got := clientToolsWithoutSections(page); got != "The caller's page\nFooter" { + t.Fatalf("child page: %q", got) + } + for _, config := range []Config{{ClientTools: door, InTask: true}, {ClientTools: door, Errand: true}} { + _, sets := config.clientToolSets() + if len(sets) != 0 || clientToolsSection(config) != "" { + t.Fatal("child inherited a set") + } + } + started := make(chan struct{}) + parent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("original", "write", `{"path":"note.txt","content":"note"}`), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(c *Config) { + c.System, c.PromptProfile = "", "full" + c.ClientTools = door + c.AskConsent, c.Interactive = true, true + c.ApprovalPolicy = promptAll() + }) + if parent.config.ClientTools == nil || !strings.Contains(parent.system, "Tools from the app: notes") { + t.Fatal("parent lacks the capability this test must exclude") + } + cfg := parent.config + cfg.System = parent.system + cfg.SessionFile, cfg.Place = "", Place{} + cfg.InTask = true + child, err := parent.newChildAgent(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = child.Close() }) + clientToolsAssertExcluded(t, child) + + watch, stop := parent.WatchQuestions() + defer stop() + mustSubmit(t, parent, "write note.txt") + q := discussionWait(t, watch, func(e Event) bool { return e.Kind == EventQuestion }).Question + if err := parent.ResolveQuestion(Answer{Kind: q.Kind, ID: q.ID, Clarify: true, AskedBack: []Exchange{{Asked: "why this note?"}}}); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("clarification did not start") + } + parent.mu.Lock() + clarification := parent.discussions["clarify-1"].child + parent.mu.Unlock() + clientToolsAssertExcluded(t, clarification) +} + +func clientToolsAssertExcluded(t *testing.T, child *Agent) { + t.Helper() + if child.config.ClientTools != nil { + t.Fatal("child inherited the client door") + } + for _, page := range []string{child.config.System, child.system} { + if strings.Contains(page, "Tools from the app") || strings.Contains(page, "These are still the app's instructions.") { + t.Fatal("child inherited app instructions") + } + } + for _, definition := range child.beltDefinitions() { + if definition.Function.Name == "app_note" { + t.Fatal("child offers a client tool") + } + } +} diff --git a/internal/session/connect.go b/internal/session/connect.go index a900a1f1ab..58b814e1d6 100644 --- a/internal/session/connect.go +++ b/internal/session/connect.go @@ -563,7 +563,9 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { held := make(map[string]bool, len(a.tools)) for _, tool := range a.tools { - held[tool.Name] = true + if _, client := a.clientTools.names[tool.Name]; !client { + held[tool.Name] = true + } } var arriving []bare.Tool var names []string @@ -573,6 +575,7 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { } arriving = append(arriving, tool) names = append(names, tool.Name) + held[tool.Name] = true } if len(arriving) == 0 { return nil, nil @@ -584,12 +587,14 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { // Fresh arrays, exact length: the old ones are still being walked by // whatever took a snapshot a moment ago, and appending into spare capacity // would write into the array they are reading. - grownTools := make([]bare.Tool, 0, len(a.tools)+len(arriving)) - grownTools = append(append(grownTools, a.tools...), arriving...) - grownDefinitions := make([]ai.ToolDefinition, 0, len(a.definitions)+len(definitions)) - grownDefinitions = append(append(grownDefinitions, a.definitions...), definitions...) - a.tools = grownTools - a.definitions = grownDefinitions + builtins, builtinDefinitions, err := a.clientToolBuiltinsLocked(len(arriving)) + if err != nil { + return nil, err + } + builtins = append(builtins, arriving...) + a.tools = a.clientToolTailLocked(builtins, a.clientTools.sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(append(builtinDefinitions, definitions...), tail...) for _, tool := range arriving { known := false for _, kept := range a.profileArmed { diff --git a/internal/session/inherit.go b/internal/session/inherit.go index b4fa6f16ca..4128971588 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -387,6 +387,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. child.system = promotedSystem(workstreamWithoutPage(quick.system)) + child.system = clientToolsWithoutSections(child.system) child.systemOwn = false messages[0] = textMessage("system", child.system) child.messages = messages diff --git a/internal/session/loop.go b/internal/session/loop.go index e59816da6e..e29cc2a8bd 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3547,7 +3547,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa if service != "" { serviceName = a.serviceName(service) } - hub.send(Event{ + hub.send(a.clientToolEvent(Event{ Kind: EventToolBegin, Tool: call.Function.Name, Hint: a.gloss(call), @@ -3556,7 +3556,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa Batch: batch, Service: service, ServiceName: serviceName, - }) + })) } // THE NARRATOR ARMS BESIDE THE WORK, not after a long silence. A half-second @@ -3948,6 +3948,9 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca if notice, retired := a.teamRetiredNotice(call.Function.Name); retired { return toolResult{text: notice, isError: true, harness: true} } + if notice, retired := a.clientToolRetiredNotice(call.Function.Name); retired { + return toolResult{text: notice, isError: true, harness: true} + } // A name nobody ever had keeps the old answer, and keeps it word for word: // that one IS a sentence about the model. return toolResult{text: "Unknown tool: " + call.Function.Name, isError: true} diff --git a/internal/session/prompt.go b/internal/session/prompt.go index ef40bedc69..0295e27912 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -401,6 +401,7 @@ func renderSystemAt(config Config, now time.Time) string { out.WriteString(workstreamSection(config)) out.WriteString(workerFooter(config, now)) + out.WriteString(clientToolsSection(config)) return out.String() } diff --git a/internal/session/promptprofile_live.go b/internal/session/promptprofile_live.go index 4a1d55e675..a2abd43f3b 100644 --- a/internal/session/promptprofile_live.go +++ b/internal/session/promptprofile_live.go @@ -52,6 +52,7 @@ func (a *Agent) preparePromptProfile(model string) error { state.current.Store(next) tools := a.belt() a.armMu.Lock() + tools = a.withoutClientToolsLocked(tools) // Explicitly loaded capabilities and connected services remain usable. // Only the default belt is repartitioned when the window changes. held := make(map[string]bool, len(tools)) @@ -80,6 +81,11 @@ func (a *Agent) preparePromptProfile(model string) error { held[tool.Name] = true } } + if !next.chat() { + tools = a.clientToolTailLocked(tools, a.clientTools.sets) + } else { + a.clientTools.names = nil + } definitions, err := toolDefinitions(tools) if err != nil { a.shelf, a.shelfOrder, a.prearm = oldShelf, oldOrder, oldPrearm diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index a5aa7305d6..0533a7e9ef 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -97,6 +97,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { a.mu.Lock() cfg := a.config cfg.Workstream = nil + cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -109,6 +110,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { cfg.ApprovalPolicy = a.config.ApprovalPolicy } cfg.System = workstreamWithoutPage(a.system) + cfg.System = clientToolsWithoutSections(cfg.System) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride } diff --git a/internal/session/session.go b/internal/session/session.go index 7222ef353f..393e328ff0 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -852,6 +852,8 @@ type Event struct { // a surface reads as "unknown" and never as "nothing happened". Compaction *CompactionFact `json:"Compaction,omitempty"` + // Tool lifecycle events can also carry the conversation's declared client-tool + // category, so surfaces need no name-only guess for those dynamic tools. // Category is the FAMILY OF WORK an EventCaption's sentence is about — one // word from the closed list in actioncategory.go — and it is zero on every // other kind. @@ -2526,6 +2528,9 @@ type Config struct { // Workstream is set by the app only on a workstream's own agent. Nil is no door. Workstream WorkstreamDoor + // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. + ClientTools ClientToolDoor + // factoryWindow overrides how long one factory card holds its tool call // open, for tests that watch the window end. Zero is [factoryCardWindow]. factoryWindow time.Duration @@ -2908,21 +2913,20 @@ type Agent struct { systemAt time.Time systemOwn bool // tools is the belt and definitions is its wire form, built once at - // construction — rebuilding them per step would re-marshal every schema on - // the hot path — and thereafter grown only at the tail under armMu - // (connect.go), with one exception: the team verbs leave when the role that - // gave them does (team.go's [Agent.retireTeamTools]). + // construction. Built-ins grow under armMu (connect.go), before the replaceable + // client-tool tail. Team verbs leave when their role ends (team.go). // // Both are COPY-ON-WRITE: arming allocates a new array and swaps the header, // so a reader that took a snapshot under armMu may walk it without the lock // and can never see a half-written slice. Nothing already in either is ever - // moved or rewritten, and nothing but a team verb is removed, because the - // definition block rides at the front of every request and a definition that - // shifts re-bills the whole prompt behind it (internal/exec's tools.go states - // the law). + // moved or rewritten in place. Built-ins keep their order while client sets + // can be replaced. The definition block rides at the front of every request, + // so a definition that shifts re-bills the whole prompt behind it + // (internal/exec's tools.go states the law). presentation *presentationIndex tools []bare.Tool definitions []ai.ToolDefinition + clientTools clientToolState // served is what the belt cannot say about the tools an ACCOUNT named // rather than this build (served.go): whose account each one is, what the // account calls it, and which capability governs it. Keyed by the name the diff --git a/internal/session/task_app_steps.go b/internal/session/task_app_steps.go index d1fbf05064..80e1fc2aa9 100644 --- a/internal/session/task_app_steps.go +++ b/internal/session/task_app_steps.go @@ -248,7 +248,7 @@ func (l *taskLive) openStep(event Event, at time.Time) { ID: appLiveStepID(event, len(l.calls)), Call: event.CallID, Tool: strings.TrimSpace(event.Tool), - Category: ActionCategoryForTool(event.Tool), + Category: toolEventCategory(event), Target: appStepText(event.Hint), Args: event.Args, Running: true, diff --git a/internal/session/task_quick_files.go b/internal/session/task_quick_files.go index b7174a0bb1..fc56495bf8 100644 --- a/internal/session/task_quick_files.go +++ b/internal/session/task_quick_files.go @@ -108,6 +108,9 @@ func (a *Agent) executeTaskTool(ctx context.Context, tool bare.Tool, call ai.Too // readingToolRefusal also guards calls named off the belt. The names come from // the same set that arms the hand-off, so its promise cannot drift from it. func (a *Agent) readingToolRefusal(name string) string { + if category, client := a.clientToolCategory(name); client && category == ActionRead { + return "" + } if !a.config.readingTask() || earlyTools[name] { return "" } diff --git a/internal/session/team.go b/internal/session/team.go index 35ce4313b0..027fbc340d 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -488,6 +488,7 @@ func memberOf(team teams.Team, keys []string) (teams.Member, bool) { // and a demotion is rare enough that paying once for it is cheap. A role // gained again appends its verbs at the tail as before. func (a *Agent) teamBoundary() string { + a.clientToolsBoundary() profile := a.config.teamProfile() if profile == "" { return "" diff --git a/internal/session/tools.go b/internal/session/tools.go index e6820913ab..d9958b616e 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -335,7 +335,7 @@ func (a *Agent) belt() []bare.Tool { // (tools_capabilities.go). It removes no capability and adds none: a machine // with no media models still has no media group, and a shelf with nothing on // it puts no verb on this belt at all. - return a.shelveDeferred(tools) + return a.appendClientTools(a.shelveDeferred(tools)) } // toolDefinitions builds the wire form of the belt, carrying each tool's From 696bc860c647215e792e2bf306c669ca3233f6ac Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 15:57:49 -0500 Subject: [PATCH 3/7] A conversation any collection holds is not blank-chat litter Launch cleanup, the blank-chat sweep and the background places sweep keep a never-spoken conversation that a collection references, reading the collections database read-only and keeping the folder when it cannot be read. Opening such a conversation by id after a restart restores its owned, model, archive and spend facts from its metadata. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 6 +- cmd/codeaf/appcollectionkeep.go | 39 ++++ cmd/codeaf/appcollectionkeep_test.go | 272 +++++++++++++++++++++++++++ cmd/codeaf/chatv3_layout.go | 2 +- cmd/codeaf/chatv3_sweep.go | 4 +- cmd/codeaf/chatv3_sweep_test.go | 2 +- internal/session/sweep.go | 23 ++- internal/session/sweep_test.go | 4 +- 8 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 cmd/codeaf/appcollectionkeep.go create mode 100644 cmd/codeaf/appcollectionkeep_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 87efd28ad7..3a372cc369 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1250,10 +1250,12 @@ func appRowOnDisk(id string) (session.SessionRow, bool) { // A folder minted and never spoken in is still a conversation the app // may have created; its meta names the workspace. meta, err := session.LoadMeta(dir) - if err != nil || strings.TrimSpace(meta.ID) == "" { + if err != nil || meta.ID != id { continue } - return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, Title: meta.Title, Workspace: meta.Workspace, Created: meta.Created}, true + return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, + Title: meta.Title, Workspace: meta.Workspace, Owned: meta.Owned, Model: meta.Model, + Archived: meta.Archived, Spend: meta.SpentUSD, At: meta.Created, Created: meta.Created}, true } return session.SessionRow{}, false } diff --git a/cmd/codeaf/appcollectionkeep.go b/cmd/codeaf/appcollectionkeep.go new file mode 100644 index 0000000000..b64f4bfb65 --- /dev/null +++ b/cmd/codeaf/appcollectionkeep.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/sqlitefile" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// A collection's reference makes a bare conversation intentional. Read-only mode +// also protects against the store disappearing between Stat and the SQLite open. +func collectionKeepsConversation(dir string) bool { + path := home.Join("v3", "collections.db") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return false + } else if err != nil { + return true + } + db, err := sql.Open("sqlite", sqlitefile.URI(path, "mode=ro&_pragma=busy_timeout(100)")) + if err != nil { + return true + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var kept bool + // These membership columns exist in every collections schema. EXISTS avoids + // loading collection metadata or interpreting any newer collection features. + err = db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships m + JOIN collections c ON c.id=m.collection_id WHERE m.kind=? AND m.ref_id=? AND m.session_id='')`, + workspace.ConversationKind, filepath.Base(filepath.Clean(dir))).Scan(&kept) + return kept || err != nil +} diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go new file mode 100644 index 0000000000..2ca6be0486 --- /dev/null +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func collectionKeepEnvironment(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + bucket, err := v3ProjectDir(root) + if err != nil { + t.Fatal(err) + } + return root, bucket +} + +// Contract (g): collection-held blanks survive launch and the app's cleanup. +func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if v3EmptyUnassignedSession(held) || v3EmptySession(held) { + t.Fatal("collection-held conversation is litter") + } + if !v3EmptyUnassignedSession(unheld) { + t.Fatal("unheld empty conversation was retained") + } + v3ReapEmpty([]v3Folder{{dir: held}, {dir: unheld}}, "") + if _, err := os.Stat(held); err != nil { + t.Fatal("launch reap removed collection-held conversation") + } + if _, err := os.Stat(unheld); !os.IsNotExist(err) { + t.Fatalf("launch reap kept unheld conversation: %v", err) + } + b := newFakeBackend(newAppRecorder(), nil) + b.profileDir = config.ProfileDir() + row, ok := appRowOnDisk(filepath.Base(held)) + if !ok { + t.Fatal("bare row missing") + } + b.remember(row) + a := b.agent(row.ID) + b.locked(func() { a.minted = true }) + if b.reapBlank(a) { + t.Fatal("app cleanup removed collection-held conversation") + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !beforeInfo.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("blank cleanup wrote collections.db") + } +} + +// Contract (g): the real launch-time sweep keeps collection-held litter and unreadable stores. +func TestCollectionHeldConversationSurvivesBackgroundSweep(t *testing.T) { + for _, unreadable := range []bool{false, true} { + name := "held and unheld" + if unreadable { + name = "unreadable store" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + old := time.Now().Add(-8 * 24 * time.Hour) + for _, dir := range []string{held, unheld} { + meta := session.Meta{ID: filepath.Base(dir), Workspace: root, LaunchDir: root, Created: old} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(dir, old, old); err != nil { + t.Fatal(err) + } + } + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + if unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + proc := &v3Process{} + proc.startPlaceSweep() + t.Cleanup(proc.stopPlaceSweep) + select { + case <-proc.sweepDone: + case <-time.After(3 * time.Second): + t.Fatal("background sweep did not finish") + } + if _, err := os.Stat(held); err != nil { + t.Fatalf("background sweep removed held conversation: %v", err) + } + _, err = os.Stat(unheld) + if unreadable && err != nil { + t.Fatalf("background sweep discarded an uncertain conversation: %v", err) + } + if !unreadable && !os.IsNotExist(err) { + t.Fatalf("background sweep kept unheld control: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !info.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("background sweep wrote collections.db") + } + }) + } +} + +// Contract (g): missing means unheld; every unreadable database means keep. +func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "empty-chat", "", time.Time{}) + path := home.Join("v3", "collections.db") + if collectionKeepsConversation(dir) { + t.Fatal("missing store retained conversation") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("read created a store") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + bad := []byte("not a SQLite database") + if err := os.WriteFile(path, bad, 0o600); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) || v3EmptyUnassignedSession(dir) { + t.Fatal("uncertainty discarded a conversation") + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, bad) { + t.Fatal("read repaired or migrated a corrupt store") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) { + t.Fatal("unopenable store discarded a conversation") + } +} + +// Contract (g): a restart recovers every identity field from a never-spoken meta. +func TestCollectionHeldConversationFindAndOpenAfterRestart(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + did, err := agentdid.New() + if err != nil { + t.Fatal(err) + } + created := time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC) + meta := session.Meta{ID: id, Workspace: root, Owned: true, Title: "Reserved notes", Model: "vendor/model", Archived: true, SpentUSD: 1.25, Created: created, + AgentIdentity: &session.AgentIdentity{DID: did, Session: id}} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: id}); err != nil { + t.Fatal(err) + } + _ = store.Close() + want := session.SessionRow{ID: id, AgentDID: did, Dir: dir, Transcript: (session.Place{Dir: dir}).Transcript(), Workspace: root, Owned: true, + Title: meta.Title, Model: meta.Model, Archived: true, Spend: meta.SpentUSD, At: created, Created: created} + row, ok := appRowOnDisk(id) + if !ok || !reflect.DeepEqual(row, want) { + t.Fatalf("identity after restart: %+v want %+v", row, want) + } + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + if ask.Transcript != want.Transcript || ask.Workspace != root || ask.Model != meta.Model { + t.Errorf("open identity: %+v", ask) + } + return &appConn{Conv: &fakeConversation{model: ask.Model}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { _ = b.Close() }) + found, err := b.find(id) + if err != nil || found.view.DID != did || found.view.Title != meta.Title || opened != 0 { + t.Fatalf("find: %+v %v", found, err) + } + view, err := b.OpenAgent(id) + if err != nil || view.ID != id || view.DID != did || view.Title != meta.Title || view.Model != meta.Model || opened != 1 { + t.Fatalf("agents.open: %+v %v", view, err) + } + if view.State == appwire.StateWorking { + t.Fatal("opening started a turn") + } +} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index ca38a344a6..d5eab92341 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -733,7 +733,7 @@ func v3EmptyUnassignedSession(dir string) bool { return false } } - return !v3WorkstreamKeeps(dir) + return !v3WorkstreamKeeps(dir) && !collectionKeepsConversation(dir) } // v3ReapEmpty removes the empty session folders a launch left behind, except diff --git a/cmd/codeaf/chatv3_sweep.go b/cmd/codeaf/chatv3_sweep.go index 0c38d8c1d6..f204396515 100644 --- a/cmd/codeaf/chatv3_sweep.go +++ b/cmd/codeaf/chatv3_sweep.go @@ -42,7 +42,7 @@ const sweepLogName = "sweep.log" // directory to walk so that the rule can be pointed at a temp directory and // proved. [v3ErrandsRoot] is the one answer to where that is, and // [errandHeldByAutomation] keeps the errands an automation was saved from. -var sweepHome func(context.Context, string, func(string) bool, func(string)) = session.SweepHomeContext +var sweepHome func(context.Context, string, func(string) bool, func(string) bool, func(string)) = session.SweepHomeContext func (p *v3Process) startPlaceSweep() { ctx, cancel := context.WithCancel(context.Background()) @@ -51,7 +51,7 @@ func (p *v3Process) startPlaceSweep() { p.sweepDone = done guard.Go("chatv3/sweep-home", func() { defer close(done) - sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), func(line string) { + sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), collectionKeepsConversation, func(line string) { if ctx.Err() == nil { noteSweep(line) } diff --git a/cmd/codeaf/chatv3_sweep_test.go b/cmd/codeaf/chatv3_sweep_test.go index d4b6f6ee8f..e84a0e580b 100644 --- a/cmd/codeaf/chatv3_sweep_test.go +++ b/cmd/codeaf/chatv3_sweep_test.go @@ -28,7 +28,7 @@ func TestCloseJoinsPlaceSweepAndNextProcessStillSweeps(t *testing.T) { t.Cleanup(func() { sweepHome = previousSweep }) var starts atomic.Int32 var destructive atomic.Int32 - sweepHome = func(ctx context.Context, _ string, _ func(string) bool, _ func(string)) { + sweepHome = func(ctx context.Context, _ string, _, _ func(string) bool, _ func(string)) { i := int(starts.Add(1)) - 1 if i >= len(passes) { t.Errorf("unexpected sweep start %d", i+1) diff --git a/internal/session/sweep.go b/internal/session/sweep.go index 6ffd199a76..eaea595da6 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -92,18 +92,19 @@ const ( // an errand's folder is still somebody's — an automation was saved from it — // and may be nil. func SweepHome(errandsRoot string, held func(dir string) bool, note func(string)) { - SweepHomeContext(context.Background(), errandsRoot, held, note) + SweepHomeContext(context.Background(), errandsRoot, held, nil, note) } // SweepHomeContext is SweepHome with cancellation. Cancellation is checked // before resolving the home so a stopped launch cannot target a home selected -// after it started. -func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir string) bool, note func(string)) { +// after it started. A non-nil keep preserves project conversations retained by +// the caller; held independently preserves errands saved by automations. +func SweepHomeContext(ctx context.Context, errandsRoot string, held, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } now := time.Now() - SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, note) + SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, keep, note) if contextDone(ctx) { return } @@ -115,12 +116,13 @@ func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir str // A root that is not there is not a failure — it is a machine that has not held // a conversation yet — and answers silently. func SweepPlaces(root string, now time.Time, note func(string)) { - SweepPlacesContext(context.Background(), root, now, note) + SweepPlacesContext(context.Background(), root, now, nil, note) } // SweepPlacesContext is SweepPlaces with cancellation between entries and -// immediately before each destructive operation. -func SweepPlacesContext(ctx context.Context, root string, now time.Time, note func(string)) { +// immediately before each destructive operation. A non-nil keep can retain a +// conversation that would otherwise be litter, without preventing log expiry. +func SweepPlacesContext(ctx context.Context, root string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } @@ -155,7 +157,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu if !entry.IsDir() { continue } - sweepSession(ctx, filepath.Join(path, entry.Name()), now, note) + sweepSession(ctx, filepath.Join(path, entry.Name()), now, keep, note) } } } @@ -163,7 +165,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu // sweepSession is the three rules over one session folder, in the order that // makes rule 3 unconditional: the live check first, and nothing at all happens // to a folder that fails it. -func sweepSession(ctx context.Context, dir string, now time.Time, note func(string)) { +func sweepSession(ctx context.Context, dir string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) || sessionIsOpen(dir) { // Somebody is talking to it. Not its logs, not its folder, nothing. return @@ -185,6 +187,9 @@ func sweepSession(ctx context.Context, dir string, now time.Time, note func(stri retireCheckpointForks(ctx, dir, true, note) return } + if keep != nil && keep(dir) { + return + } reapSession(ctx, dir, meta, note) } diff --git a/internal/session/sweep_test.go b/internal/session/sweep_test.go index a5287ee018..14b150e8cc 100644 --- a/internal/session/sweep_test.go +++ b/internal/session/sweep_test.go @@ -543,7 +543,7 @@ func TestSweepHomeContextCancelledBeforeStartTouchesNothing(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - SweepHomeContext(ctx, errandsRoot, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) + SweepHomeContext(ctx, errandsRoot, nil, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) for _, kept := range []string{place, staleLog, exchange} { if _, err := os.Stat(kept); err != nil { @@ -564,7 +564,7 @@ func TestSweepPlacesContextCancellationStopsLaterDestructiveOperations(t *testin first, firstTree := newSweptUniverseSession(t, root, "1111111111111111", repo, now) second, secondTree := newSweptUniverseSession(t, root, "2222222222222222", repo, now) ctx, cancel := context.WithCancel(context.Background()) - SweepPlacesContext(ctx, root, now, func(line string) { + SweepPlacesContext(ctx, root, now, nil, func(line string) { if strings.Contains(line, firstTree.universe) { cancel() } From 81d031423901a41cd3a870df71f0d40d03ee9b79 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 15:57:54 -0500 Subject: [PATCH 4/7] codeaf app sends client tool calls to the connected app The app backend keeps each conversation's client tool sets for as long as the connection lasts, emits one agents.tool_call per model call and waits for the answer, its deadline, the turn's end or the connection closing. Call ids are stable for a replayed call, and a late or repeated answer is ignored. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 4 + cmd/codeaf/appclienttools.go | 500 +++++++++++++++++++++ cmd/codeaf/appclienttools_test.go | 719 ++++++++++++++++++++++++++++++ cmd/codeaf/apptools.go | 16 +- cmd/codeaf/engine.go | 1 + 5 files changed, 1235 insertions(+), 5 deletions(-) create mode 100644 cmd/codeaf/appclienttools.go create mode 100644 cmd/codeaf/appclienttools_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 3a372cc369..158db343d5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -202,6 +202,7 @@ func runApp(args []string) error { services := openAppServices() appServiceDoor.Store(services) appWorkstreamAgents.Store(backend) + appClientToolsBackend.Store(backend) backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) // THE APP IS AN OPEN WINDOW for as long as it runs, so the clock that runs @@ -553,6 +554,7 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { + clientTools appClientTools chats appChats pair appPair drives *driveBook @@ -4478,6 +4480,8 @@ func (b *appBackend) Close() error { if b.leaving != nil { b.leaving() } + b.clientTools.disconnect() + appClientToolsBackend.CompareAndSwap(b, nil) already := false var crews *appCrewRun var agents []*appAgent diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go new file mode 100644 index 0000000000..4b88e61d07 --- /dev/null +++ b/cmd/codeaf/appclienttools.go @@ -0,0 +1,500 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +const clientToolSettledTTL = 10 * time.Minute +const clientToolSettledMax = 1024 + +var appClientToolsBackend atomic.Pointer[appBackend] +var clientToolFreshID atomic.Uint64 + +type clientToolAgentSets struct { + version uint64 + sets []session.ClientToolSet + builtins map[string]bool +} + +type clientToolPending struct { + ctx context.Context + done chan struct{} + call session.ClientToolCall + deadline time.Time + answer session.ClientToolAnswer + event *appwire.AgentsToolCall +} + +type clientToolSettled struct { + answer session.ClientToolAnswer + at time.Time +} + +// The registry belongs to one stdio connection, including its replay memory. +type appClientTools struct { + mu sync.Mutex + agents map[string]clientToolAgentSets + pending map[string]*clientToolPending + settled map[string]clientToolSettled + order []string + closed bool + senderWake chan struct{} + senderDone chan struct{} +} + +type appClientToolDoor struct { + backend *appBackend + agent string +} + +var _ appwire.ClientTooler = (*appBackend)(nil) +var _ session.ClientToolDoor = (*appClientToolDoor)(nil) + +func (b *appBackend) clientToolEventCategory(a *appAgent, event session.Event) session.ActionCategory { + if event.Category != "" { + return event.Category + } + var id string + var conv appConversation + b.locked(func() { + id = a.view.ID + if a.live != nil { + conv = a.live.conn.Conv + } + }) + if local, ok := conv.(interface { + ActionCategoryForTool(string) session.ActionCategory + }); ok { + return local.ActionCategoryForTool(event.Tool) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.agents[id].builtins[event.Tool] { + return session.ActionCategoryForTool(event.Tool) + } + for _, set := range r.agents[id].sets { + for _, tool := range set.Tools { + if tool.Name == event.Tool { + return tool.Category + } + } + } + return session.ActionCategoryForTool(event.Tool) +} + +func appClientToolsDoor(sessionFile string) session.ClientToolDoor { + b := appClientToolsBackend.Load() + if b == nil { + return nil + } + return &appClientToolDoor{backend: b, agent: appAgentID(sessionFile)} +} + +// Root builders share launch settings, but each opening needs its own conversation door. +func appClientToolsOpener(open func(session.Config) (*session.Agent, error)) func(session.Config) (*session.Agent, error) { + return func(cfg session.Config) (*session.Agent, error) { + cfg.ClientTools = appClientToolsDoor(cfg.SessionFile) + return open(cfg) + } +} + +func (b *appBackend) SetClientTools(p appwire.ClientToolsSetParams) (appwire.ClientToolsSetResult, error) { + result := appwire.ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: []string{}} + if err := p.Validate(); err != nil { + return result, err + } + a, err := b.find(p.Agent) + if err != nil { + return result, err + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.clientToolScope(a); err != nil { + return result, err + } + held := b.clientToolBuiltinNames(a) + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return result, appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + state := r.agents[p.Agent] + for name := range state.builtins { + held[name] = true + } + if err := clientToolReplacementValid(p, state.sets, held); err != nil { + return result, err + } + sets := make([]session.ClientToolSet, 0, len(state.sets)+1) + for _, set := range state.sets { + if set.ID != p.Set { + sets = append(sets, set) + } + } + if len(p.Tools) > 0 { + set := clientToolSnapshot(p) + sets = append(sets, set) + for _, tool := range set.Tools { + result.Tools = append(result.Tools, tool.Name) + } + } + sort.Slice(sets, func(i, j int) bool { return sets[i].ID < sets[j].ID }) + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[p.Agent] = clientToolAgentSets{version: state.version + 1, sets: sets, builtins: state.builtins} + return result, nil +} + +func (b *appBackend) clientToolScope(a *appAgent) error { + remoteHeld := false + b.locked(func() { + if a.live != nil { + _, remoteHeld = a.live.conn.Conv.(*remote.Agent) + remoteHeld = remoteHeld && !a.live.conn.Assembled && !a.live.conn.Lent + } + remoteHeld = remoteHeld || a.view.Location != nil && !a.view.Location.Here + }) + if remoteHeld { + return appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") + } + return nil +} + +// Local app conversations also use a transport proxy. Their session reports its +// built-in inventory here, so validation still sees the actual engine belt. +func (d *appClientToolDoor) ClientToolBuiltins(names []string) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + state := r.agents[d.agent] + state.builtins = make(map[string]bool, len(names)) + for _, name := range names { + state.builtins[name] = true + } + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[d.agent] = state +} + +func (b *appBackend) clientToolBuiltinNames(a *appAgent) map[string]bool { + var conv appConversation + b.locked(func() { + if a.live != nil { + conv = a.live.conn.Conv + } + }) + held := make(map[string]bool) + if live, ok := conv.(interface{ ClientToolBuiltinNames() []string }); ok { + for _, name := range live.ClientToolBuiltinNames() { + held[name] = true + } + } + return held +} + +func clientToolReplacementValid(p appwire.ClientToolsSetParams, sets []session.ClientToolSet, builtins map[string]bool) error { + names := make(map[string]bool) + count, setCount := len(p.Tools), 0 + for _, set := range sets { + if set.ID == p.Set { + continue + } + setCount++ + count += len(set.Tools) + for _, tool := range set.Tools { + names[tool.Name] = true + } + } + if len(p.Tools) > 0 { + setCount++ + } + if setCount > session.ClientToolMaxSets || count > session.ClientToolMaxPerAgent { + return appwire.Errorf(appwire.CodeBadParams, "an agent allows at most %d sets and %d client tools", session.ClientToolMaxSets, session.ClientToolMaxPerAgent) + } + for _, tool := range p.Tools { + if builtins[tool.Name] || session.ActionCategoryForTool(tool.Name) != session.ActionWork { + return appwire.Errorf(appwire.CodeBadParams, "%s is a built-in engine tool", tool.Name) + } + if names[tool.Name] { + return appwire.Errorf(appwire.CodeBadParams, "%s is already in another set", tool.Name) + } + } + return nil +} + +func clientToolSnapshot(p appwire.ClientToolsSetParams) session.ClientToolSet { + set := session.ClientToolSet{ID: p.Set, Instructions: p.Instructions, DeadlineMS: p.DeadlineMS, + Tools: append([]session.ClientTool(nil), p.Tools...)} + if set.DeadlineMS == 0 { + set.DeadlineMS = session.ClientToolDefaultDeadlineMS + } + for i := range set.Tools { + set.Tools[i].InputSchema = bytes.Clone(set.Tools[i].InputSchema) + } + return set +} + +func (d *appClientToolDoor) ClientToolSets() (uint64, []session.ClientToolSet) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + state := r.agents[d.agent] + return state.version, state.sets +} + +func clientToolCallID(agent, provider string) string { + if provider == "" { + return fmt.Sprintf("ct_fresh_%x", clientToolFreshID.Add(1)) + } + sum := sha256.Sum256([]byte(agent + "\x00" + provider)) + return "ct_" + hex.EncodeToString(sum[:]) +} + +func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if err := ctx.Err(); err != nil { + return clientToolTurnEnded(call.Name), nil + } + var object map[string]json.RawMessage + if json.Unmarshal(call.Args, &object) != nil || object == nil { + return session.ClientToolAnswer{Text: "Invalid arguments: expected a JSON object", IsError: true}, nil + } + id := clientToolCallID(d.agent, call.TurnCall) + r := &d.backend.clientTools + r.mu.Lock() + pending, answer, emit := r.beginLocked(ctx, d.agent, id, call) + r.mu.Unlock() + if pending == nil { + return answer, nil + } + if emit { + var turn uint64 + d.backend.locked(func() { + if a := d.backend.agents[d.agent]; a != nil && a.live != nil { + turn = a.live.turn + } + }) + r.queueEmission(d.backend.emit, pending, appwire.AgentsToolCall{Agent: d.agent, Turn: turn, + Call: id, TurnCall: call.TurnCall, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) + } + return r.wait(ctx, id, pending), nil +} + +// One sender owns all writer waits. Queued events live only in pending calls, +// so calls that end behind a blocked writer leave no stale queue to grow. +func (r *appClientTools) queueEmission(emit appwire.Emitter, pending *clientToolPending, event appwire.AgentsToolCall) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.pending[event.Call] != pending { + return + } + pending.event = &event + if r.senderWake == nil { + r.senderWake = make(chan struct{}, 1) + r.senderDone = make(chan struct{}) + go r.sendEvents(emit) + } + select { + case r.senderWake <- struct{}{}: + default: + } +} + +func (r *appClientTools) sendEvents(emit appwire.Emitter) { + defer close(r.senderDone) + for range r.senderWake { + for { + event := r.nextEmission() + if event == nil { + break + } + emit.Emit(appwire.EventAgentsToolCall, *event) + } + } +} + +func (r *appClientTools) nextEmission() *appwire.AgentsToolCall { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil + } + for _, pending := range r.pending { + event := pending.event + pending.event = nil + if event != nil && pending.ctx.Err() == nil && time.Now().Before(pending.deadline) { + return event + } + } + return nil +} + +// Register before emitting, so even a synchronous client answer finds its waiter. +func (r *appClientTools) beginLocked(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.pruneLocked(time.Now()) + if settled, ok := r.settled[id]; ok { + return nil, settled.answer, false + } + if pending := r.pending[id]; pending != nil { + return pending, session.ClientToolAnswer{}, false + } + deadline, attached := r.attachedLocked(agent, call.Set, call.Name) + if r.closed || !attached { + return nil, session.ClientToolUnavailable(call.Name), false + } + if call.DeadlineMS >= session.ClientToolMinDeadlineMS && call.DeadlineMS <= session.ClientToolMaxDeadlineMS { + deadline = call.DeadlineMS + } + call.DeadlineMS = deadline + pending := &clientToolPending{ctx: ctx, done: make(chan struct{}), call: call, deadline: time.Now().Add(time.Duration(deadline) * time.Millisecond)} + if r.pending == nil { + r.pending = make(map[string]*clientToolPending) + } + r.pending[id] = pending + return pending, session.ClientToolAnswer{}, true +} + +func (r *appClientTools) attachedLocked(agent, setID, name string) (int, bool) { + for _, set := range r.agents[agent].sets { + if set.ID != setID { + continue + } + for _, tool := range set.Tools { + if tool.Name == name { + return set.DeadlineMS, true + } + } + } + return 0, false +} + +func (r *appClientTools) wait(ctx context.Context, id string, pending *clientToolPending) session.ClientToolAnswer { + timer := time.NewTimer(time.Until(pending.deadline)) + defer timer.Stop() + var failed session.ClientToolAnswer + select { + case <-pending.done: + return pending.answer + case <-timer.C: + failed = clientToolTimeout(pending.call) + case <-ctx.Done(): + failed = clientToolTurnEnded(pending.call.Name) + } + r.mu.Lock() + defer r.mu.Unlock() + r.settleLocked(id, pending, failed) + return pending.answer +} + +func clientToolTimeout(call session.ClientToolCall) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_timeout: the app that provides %s did not answer within %gs; the outcome is unknown, so check before trying again.", call.Name, float64(call.DeadlineMS)/1000), IsError: true} +} + +func clientToolTurnEnded(name string) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("the turn ended before the app answered %s; the outcome is unknown, so check before trying again.", name), IsError: true} +} + +func (r *appClientTools) settleLocked(id string, pending *clientToolPending, answer session.ClientToolAnswer) bool { + if r.pending[id] != pending { + return false + } + delete(r.pending, id) + pending.answer = answer + if r.settled == nil { + r.settled = make(map[string]clientToolSettled) + } + r.pruneLocked(time.Now()) + if len(r.order) == clientToolSettledMax { + delete(r.settled, r.order[0]) + r.order = r.order[1:] + } + r.settled[id] = clientToolSettled{answer: answer, at: time.Now()} + r.order = append(r.order, id) + close(pending.done) + return true +} + +func (r *appClientTools) pruneLocked(now time.Time) { + for len(r.order) > 0 { + id := r.order[0] + if now.Sub(r.settled[id].at) < clientToolSettledTTL { + break + } + delete(r.settled, id) + r.order = r.order[1:] + } +} + +func (b *appBackend) ClientToolResult(p appwire.ClientToolResultParams) (appwire.ClientToolResultAck, error) { + if err := p.Validate(); err != nil { + return appwire.ClientToolResultAck{}, err + } + var result string + if p.Error == nil { + var compact bytes.Buffer + _ = json.Compact(&compact, p.Result) + result = session.BoundClientToolOutput(compact.String()) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + pending := r.pending[p.Call] + if pending == nil { + return appwire.ClientToolResultAck{}, nil + } + if pending.ctx.Err() != nil { + r.settleLocked(p.Call, pending, clientToolTurnEnded(pending.call.Name)) + return appwire.ClientToolResultAck{}, nil + } + if !time.Now().Before(pending.deadline) { + r.settleLocked(p.Call, pending, clientToolTimeout(pending.call)) + return appwire.ClientToolResultAck{}, nil + } + answer := session.ClientToolAnswer{} + if p.Error != nil { + answer.Text = fmt.Sprintf("%s could not do %s: %s", pending.call.Set, pending.call.Name, *p.Error) + answer.IsError = true + } else { + answer.Text = result + } + return appwire.ClientToolResultAck{Accepted: r.settleLocked(p.Call, pending, answer)}, nil +} + +// Disconnect precedes conversation shutdown, so pending calls receive the owner-loss answer. +func (r *appClientTools) disconnect() { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + r.closed = true + if r.senderWake != nil { + close(r.senderWake) + } + for agent, state := range r.agents { + r.agents[agent] = clientToolAgentSets{version: state.version + 1} + } + for id, pending := range r.pending { + answer := session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", pending.call.Name), IsError: true} + r.settleLocked(id, pending, answer) + } +} diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go new file mode 100644 index 0000000000..cb9326c985 --- /dev/null +++ b/cmd/codeaf/appclienttools_test.go @@ -0,0 +1,719 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolEmitter struct { + mu sync.Mutex + calls []appwire.AgentsToolCall + seen chan appwire.AgentsToolCall + answer func(appwire.AgentsToolCall) +} + +// Contract (a), (b), (e): a real session provider request crosses the app door, +// then a turn started after disconnect loses the tools and their instructions. +func TestClientToolsModelWireRoundTripAndDisconnectBoundary(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + var mu sync.Mutex + var requests []struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + foreground := len(request.Tools) > 0 + first := foreground && len(requests) == 0 + if foreground { + requests = append(requests, request) + } + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + if first { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-client-1","type":"function","function":{"name":"app_note","arguments":"{\"id\":3}"}}]},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + disconnected := make(chan struct{}) + emitter.answer = func(event appwire.AgentsToolCall) { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "note": "found" }`)}) + if err != nil || !ack.Accepted { + t.Errorf("wire answer: %+v %v", ack, err) + } + b.clientTools.disconnect() + close(disconnected) + } + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := agent.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + turnCall := "" + for { + select { + case event, open := <-events: + if !open { + goto finished + } + if event.Kind == session.EventToolBegin && event.Tool == "app_note" { + turnCall = event.CallID + } + case <-ctx.Done(): + t.Fatal("model round trip did not finish") + } + } +finished: + clientToolAwait(t, disconnected) + // Start this turn after disconnect has completed, independently of sender scheduling. + next, err := agent.Submit(ctx, "what tools remain?") + if err != nil { + t.Fatal(err) + } + for { + select { + case _, open := <-next: + if !open { + goto nextFinished + } + case <-ctx.Done(): + t.Fatal("post-disconnect turn did not finish") + } + } +nextFinished: + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != turnCall || turnCall != "provider-client-1" { + t.Fatalf("turn.tool correlation: %+v / %q", event, turnCall) + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 3 { + t.Fatalf("provider requests=%d", len(requests)) + } + first, second, afterDisconnect := requests[0], requests[1], requests[2] + if first.Tools[len(first.Tools)-1].Function.Name != "app_note" || !strings.Contains(string(first.Messages[0].Content), "Tools from the app") { + t.Fatal("initial request lost app definitions or instructions") + } + for _, tool := range afterDisconnect.Tools { + if tool.Function.Name == "app_note" { + t.Fatal("disconnected tool remained on the next request") + } + } + if strings.Contains(string(afterDisconnect.Messages[0].Content), "Tools from the app") { + t.Fatal("disconnected instructions remained on the next request") + } + found := false + for _, message := range second.Messages { + if message.Role != "tool" || message.Call != turnCall { + continue + } + if text := clientToolWireText(message.Content); text != `{"note":"found"}` { + t.Fatalf("model result: %s", message.Content) + } + found = true + } + if !found { + t.Fatal("model did not receive the wire result") + } +} + +func clientToolWireText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var parts []struct { + Text string `json:"text"` + } + _ = json.Unmarshal(raw, &parts) + for _, part := range parts { + text += part.Text + } + return text +} + +func (e *clientToolEmitter) Emit(event string, data any) { + if event != appwire.EventAgentsToolCall { + return + } + call := data.(appwire.AgentsToolCall) + e.mu.Lock() + e.calls = append(e.calls, call) + e.mu.Unlock() + e.seen <- call + if e.answer != nil { + e.answer(call) + } +} + +func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + e := &clientToolEmitter{seen: make(chan appwire.AgentsToolCall, 20)} + b := newFakeBackend(e, nil) + id := "client-agent" + transcript := filepath.Join(t.TempDir(), id, "transcript.jsonl") + b.locked(func() { + b.agents[id] = &appAgent{view: appwire.Agent{ID: id}, transcript: transcript, + live: &appLive{turn: 7, conn: &appConn{Conv: &fakeConversation{}}}} + }) + t.Cleanup(func() { + b.clientTools.disconnect() + b.locked(func() { b.agents[id].live = nil }) + _ = b.Close() + }) + return b, &appClientToolDoor{backend: b, agent: id}, e +} + +func clientToolParams(agent, set, name string) appwire.ClientToolsSetParams { + return appwire.ClientToolsSetParams{Agent: agent, Set: set, Instructions: "Use this set.", Tools: []session.ClientTool{ + {Name: name, Description: "An app action", InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionEdit}, + }} +} + +func clientToolAttach(t *testing.T, b *appBackend, d *appClientToolDoor) session.ClientToolCall { + t.Helper() + if _, err := b.SetClientTools(clientToolParams(d.agent, "notes", "app_note")); err != nil { + t.Fatal(err) + } + return session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "provider-1", Args: json.RawMessage(`{"id":3}`)} +} + +// Contract (a), (h): the Fresh/Open builder binds the final conversation, even when its config carried another door. +func TestClientToolsFreshAndOpenBindTheirConversation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + previous := appClientToolsBackend.Swap(b) + t.Cleanup(func() { appClientToolsBackend.Store(previous) }) + root := t.TempDir() + dir := writeV3Session(t, root, "client-b", "", time.Time{}) + file := (session.Place{Dir: dir}).Transcript() + b.locked(func() { b.agents["client-b"] = &appAgent{view: appwire.Agent{ID: "client-b"}, transcript: file} }) + p := clientToolParams("client-b", "other", "app_other") + p.Instructions = "Only conversation B's instructions." + p.Tools[0].Category = session.ActionRead + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + cfg := session.Config{Workspace: root, ClientTools: door} + b.locked(func() { cfg.SessionFile = b.agents[door.agent].transcript }) + cfg.Place = session.Place{Dir: session.DirOf(cfg.SessionFile), Workspace: root} + for _, path := range []string{"fresh", "open"} { + t.Run(path, func(t *testing.T) { + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + bound, ok := opened.ClientTools.(*appClientToolDoor) + if !ok || bound.agent != "client-b" { + t.Fatalf("replacement door: %+v", opened.ClientTools) + } + _, sets := bound.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "other" || sets[0].Instructions != p.Instructions || + len(sets[0].Tools) != 1 || sets[0].Tools[0].Name != "app_other" || sets[0].Tools[0].Category != session.ActionRead { + t.Fatalf("replacement inherited the wrong sets: %+v", sets) + } + return nil, nil + }) + var next session.Config + var err error + if path == "fresh" { + next, err = v3PointAt(cfg, session.Place{Dir: dir, Workspace: root}) + } else { + next, err = v3Reopen(cfg, file, root) + } + if err != nil { + t.Fatal(err) + } + if _, err := open(next); err != nil { + t.Fatal(err) + } + }) + } + if cfg.ClientTools != door { + t.Fatal("opening a replacement changed the original config") + } + appClientToolsBackend.Store(nil) + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + if opened.ClientTools != nil { + t.Fatal("opening without an app retained a stale door") + } + return nil, nil + }) + if _, err := open(cfg); err != nil { + t.Fatal(err) + } +} + +func clientToolStart(ctx context.Context, d *appClientToolDoor, call session.ClientToolCall) <-chan session.ClientToolAnswer { + done := make(chan session.ClientToolAnswer, 1) + go func() { + answer, err := d.CallClientTool(ctx, call) + if err != nil { + answer = session.ClientToolAnswer{Text: err.Error(), IsError: true} + } + done <- answer + }() + return done +} + +func clientToolAwait[T any](t *testing.T, done <-chan T) T { + t.Helper() + select { + case answer := <-done: + return answer + case <-time.After(2 * time.Second): + t.Fatal("client tool did not settle within two seconds") + } + var zero T + return zero +} + +// Contract (b), (d): fast answers, duplicate dispatches and replay share one event. +func TestClientToolsEventResultAndReplay(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + first := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.Agent != door.agent || event.Turn != 7 || event.TurnCall != call.TurnCall || event.Set != call.Set || event.Name != call.Name || + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) { + t.Fatalf("event shape: %+v", event) + } + second := clientToolStart(context.Background(), door, call) + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "ok": true }`)}) + if err != nil || !ack.Accepted { + t.Fatalf("answer: %+v %v", ack, err) + } + for _, done := range []<-chan session.ClientToolAnswer{first, second, clientToolStart(context.Background(), door, call)} { + if answer := clientToolAwait(t, done); answer.IsError || answer.Text != `{"ok":true}` { + t.Fatalf("result: %+v", answer) + } + } + for _, id := range []string{event.Call, "unknown"} { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("duplicate/unknown: %+v %v", ack, err) + } + } + emitter.mu.Lock() + defer emitter.mu.Unlock() + if len(emitter.calls) != 1 { + t.Fatalf("%d events for one call", len(emitter.calls)) + } +} + +// Contract (b): the pending entry exists before an emitter can answer synchronously. +func TestClientToolsFastErrorAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + emitter.answer = func(event appwire.AgentsToolCall) { + text := "permission denied" + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Error: &text}) + if err != nil || !ack.Accepted { + t.Errorf("fast answer: %+v %v", ack, err) + } + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, call)) + if !answer.IsError || answer.Text != "notes could not do app_note: permission denied" { + t.Fatalf("error result: %+v", answer) + } +} + +// Contract (c), (d), (e): each ending is bounded and rejects every late answer. +func TestClientToolsDisconnectDeadlineAndTurnEnd(t *testing.T) { + for _, ending := range []string{"disconnect", "deadline", "turn end"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this in-process fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + state := b.clientTools.agents[door.agent] + state.sets[0].DeadlineMS = 15 + b.clientTools.agents[door.agent] = state + b.clientTools.mu.Unlock() + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + want := "client_timeout: the app that provides app_note did not answer within 0.015s; the outcome is unknown, so check before trying again." + switch ending { + case "disconnect": + b.clientTools.disconnect() + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + _, sets := door.ClientToolSets() + if len(sets) != 0 { + t.Fatal("disconnect kept sets") + } + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("ending: %+v", answer) + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`1`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + }) + } +} + +// Contract (c), (d): a full writer cannot hold the deadline, turn end or disconnect hostage. +func TestClientToolsBlockedEmitterDoesNotBlockEndings(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent].sets[0].DeadlineMS = 100 + b.clientTools.mu.Unlock() + } + entered, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(appwire.AgentsToolCall) { + close(entered) + <-release + close(returned) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + clientToolAwait(t, entered) + want := "client_timeout: the app that provides app_note did not answer within 0.1s; the outcome is unknown, so check before trying again." + switch ending { + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + case "disconnect": + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("blocked %s: %+v", ending, answer) + } + select { + case <-returned: + t.Fatal("emitter returned before the call ended") + default: + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + unblock() + clientToolAwait(t, returned) + b.clientTools.disconnect() + clientToolAwait(t, b.clientTools.senderDone) + }) + } +} + +// Contract (c), (d): calls ending behind a stalled writer are dropped before delivery. +func TestClientToolsEndedQueuedCallsAreNotEmitted(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect", "result"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + release := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(event appwire.AgentsToolCall) { + if event.TurnCall == "provider-1" { + <-release + } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + first := clientToolStart(ctx, door, call) + clientToolAwait(t, emitter.seen) + cancel() + clientToolAwait(t, first) + + queuedCtx, cancelQueued := context.WithCancel(context.Background()) + defer cancelQueued() + call.TurnCall = "queued-call" + id := clientToolCallID(door.agent, call.TurnCall) + r := &b.clientTools + r.mu.Lock() + pending, _, emit := r.beginLocked(queuedCtx, door.agent, id, call) + r.mu.Unlock() + if pending == nil || !emit { + t.Fatal("queued call was not registered") + } + r.queueEmission(b.emit, pending, appwire.AgentsToolCall{Agent: door.agent, Call: id, TurnCall: call.TurnCall}) + switch ending { + case "deadline": + r.mu.Lock() + pending.deadline = time.Now().Add(-time.Second) + r.mu.Unlock() + case "turn end": + cancelQueued() + case "disconnect": + r.disconnect() + case "result": + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Fatalf("queued answer: %+v %v", ack, err) + } + } + done := make(chan session.ClientToolAnswer, 1) + go func() { done <- r.wait(queuedCtx, id, pending) }() + answer := clientToolAwait(t, done) + if answer.IsError != (ending != "result") { + t.Fatalf("queued ending: %+v", answer) + } + unblock() + if ending != "disconnect" { + call.TurnCall = "after-queue" + after := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != "after-queue" { + t.Fatalf("ended call left the queue: %+v", event) + } + r.disconnect() + clientToolAwait(t, after) + } + clientToolAwait(t, r.senderDone) + select { + case event := <-emitter.seen: + t.Fatalf("extra event after queue drain: %+v", event) + default: + } + }) + } +} + +// Contract (e), (h): replacement is atomic and removal affects only its own set. +func TestClientToolsSetReplacementRemovalAndValidation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if _, err := b.SetClientTools(clientToolParams(door.agent, "second", "app_other")); err != nil { + t.Fatal(err) + } + beforeVersion, before := door.ClientToolSets() + for _, p := range []appwire.ClientToolsSetParams{ + clientToolParams(door.agent, "notes", "factory_add"), + clientToolParams(door.agent, "notes", "app_other"), + clientToolParams(door.agent, "notes", "change_setting"), + } { + if _, err := b.SetClientTools(p); err == nil || !strings.Contains(err.Error(), p.Tools[0].Name) { + t.Fatalf("collision accepted: %v", err) + } + version, sets := door.ClientToolSets() + if version != beforeVersion || !reflect.DeepEqual(sets, before) { + t.Fatal("refusal changed the registry") + } + } + replacement := clientToolParams(door.agent, "notes", "app_replaced") + if _, err := b.SetClientTools(replacement); err != nil { + t.Fatal(err) + } + replacement.Tools[0].InputSchema[0] = '[' + _, snapshots := door.ClientToolSets() + if !json.Valid(snapshots[0].Tools[0].InputSchema) { + t.Fatal("registry kept the caller's mutable schema") + } + removed, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "notes"}) + if err != nil || len(removed.Tools) != 0 { + t.Fatalf("removal: %+v %v", removed, err) + } + _, sets := door.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "second" { + t.Fatal("removal changed another set") + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, session.ClientToolCall{Set: "notes", Name: "app_replaced", Args: json.RawMessage(`{}`)})) + if !answer.IsError || answer.Text != "client_unavailable: the app that provides app_replaced is not connected; nothing was done." { + t.Fatalf("retired closure: %+v", answer) + } +} + +// Contract (h): aggregate limits and remote scope do not open a conversation. +func TestClientToolsAgentLimitsAndScope(t *testing.T) { + b, door, _ := clientToolBackend(t) + if _, err := b.SetClientTools(clientToolParams("missing", "notes", "app_note")); err == nil { + t.Fatal("unknown agent accepted") + } + for i := 0; i < session.ClientToolMaxSets; i++ { + if _, err := b.SetClientTools(clientToolParams(door.agent, fmt.Sprintf("set%d", i), fmt.Sprintf("app_%d", i))); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "fifth", "app_fifth")); err == nil { + t.Fatal("fifth set accepted") + } + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent] = clientToolAgentSets{} + b.clientTools.mu.Unlock() + for i := 0; i < 2; i++ { + p := clientToolParams(door.agent, fmt.Sprintf("set%d", i), "unused") + p.Tools = nil + for j := 0; j < session.ClientToolMaxPerSet; j++ { + p.Tools = append(p.Tools, clientToolParams(door.agent, p.Set, fmt.Sprintf("app_%d_%d", i, j)).Tools[0]) + } + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "overflow", "app_extra")); err == nil { + t.Fatal("65th tool accepted") + } + b.locked(func() { b.agents[door.agent].live.conn.Conv = &remote.Agent{} }) + if _, err := b.SetClientTools(clientToolParams(door.agent, "remote", "app_remote")); err == nil || !strings.Contains(err.Error(), "client tools need a conversation this engine runs") { + t.Fatal(err) + } + b.locked(func() { b.agents[door.agent].live.conn.Assembled = true }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("local transport proxy refused: %v", err) + } + b.locked(func() { b.agents[door.agent].live = nil }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("cold attachment opened a conversation: %v", err) + } +} + +// Contract (d): a concurrent dispatch joins the actual pending object without emitting. +func TestClientToolsPendingJoinAndCancelledAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + ctx, cancel := context.WithCancel(context.Background()) + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + r := &b.clientTools + r.mu.Lock() + first := r.pending[event.Call] + joined, _, emit := r.beginLocked(ctx, door.agent, event.Call, call) + r.mu.Unlock() + if first != joined || emit { + t.Fatal("duplicate dispatch did not join") + } + cancel() + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("cancelled turn accepted an answer: %+v %v", ack, err) + } + if answer := clientToolAwait(t, done); !answer.IsError || !strings.Contains(answer.Text, "turn ended") { + t.Fatal(answer) + } +} + +// Contract (h): local proxy inventories include dynamically armed engine names. +func TestClientToolsBuiltinInventoryFromLocalEngine(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + door.ClientToolBuiltins([]string{"app_reserved"}) + if _, err := b.SetClientTools(clientToolParams(door.agent, "other", "app_reserved")); err == nil || !strings.Contains(err.Error(), "app_reserved") { + t.Fatal(err) + } +} + +// Contract (d): replay memory expires and never exceeds its entry budget. +func TestClientToolsSettledMemoryBoundAndExpiry(t *testing.T) { + r := &appClientTools{pending: make(map[string]*clientToolPending)} + for i := 0; i < clientToolSettledMax+5; i++ { + id := fmt.Sprint(i) + pending := &clientToolPending{done: make(chan struct{})} + r.pending[id] = pending + r.settleLocked(id, pending, session.ClientToolAnswer{Text: id}) + } + if len(r.settled) != clientToolSettledMax || len(r.order) != clientToolSettledMax { + t.Fatal("unbounded replay memory") + } + r.pruneLocked(time.Now().Add(clientToolSettledTTL)) + if len(r.settled) != 0 { + t.Fatal("expired answers remain") + } + if clientToolCallID("a", "p") == clientToolCallID("b", "p") || clientToolCallID("a", "") == clientToolCallID("a", "") { + t.Fatal("call identity collision") + } +} + +// Contract (b): compact JSON is bounded with the exact number of dropped bytes. +func TestClientToolsBoundedOutputAndKinds(t *testing.T) { + data := []byte(`"` + strings.Repeat("é", session.ClientToolOutputMax) + `"`) + text := session.BoundClientToolOutput(string(data)) + if !strings.HasSuffix(text, fmt.Sprintf("[cut: %d bytes dropped]", len(data)-(session.ClientToolOutputMax-1))) { + t.Fatalf("cut marker: %.80s", text[len(text)-80:]) + } + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if category := b.clientToolEventCategory(b.agents[door.agent], session.Event{Tool: "app_note"}); category != session.ActionEdit { + t.Fatal(category) + } + if kind := appToolKind("app_note", "", session.ActionEdit); kind != appwire.ToolEdit { + t.Fatal(kind) + } +} + +// Contract (i): the feature remains usable by any app, with no domain vocabulary. +func TestClientToolsSourcesStayGeneric(t *testing.T) { + for _, path := range []string{"../../internal/appwire/client_tools.go", "../../internal/appwire/client_tools_test.go", + "../../internal/session/client_tools.go", "../../internal/session/client_tools_test.go", "appclienttools.go", "appclienttools_test.go", + "../../internal/manual/chat/client-tools.md"} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "work"+"stream") { + t.Errorf("%s contains domain vocabulary", path) + } + } +} diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 430092b0f4..9ef7f9dd25 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -133,11 +133,15 @@ func appFileName(id string) string { // appToolKind is a call's kind, read off the engine's own family for the // tool and the account it acts through, never a list kept here. -func appToolKind(tool, service string) string { +func appToolKind(tool, service string, declared ...session.ActionCategory) string { if service != "" { return appwire.ToolConnector } - switch session.ActionCategoryForTool(tool) { + category := session.ActionCategoryForTool(tool) + if len(declared) > 0 && declared[0] != "" { + category = declared[0] + } + switch category { case session.ActionSearch: if strings.HasPrefix(tool, "web_") { return appwire.ToolWeb @@ -198,6 +202,7 @@ func (b *appBackend) newTurnCalls(a *appAgent) { // callBegan records a call that began and answers its row. func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event session.Event, title string) appwire.TurnTool { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) var workspace string b.locked(func() { workspace = a.view.Workspace }) @@ -227,7 +232,7 @@ func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event ses } c.run = event.RunID c.tool, c.args, c.hint, c.title, c.turn = event.Tool, event.Args, event.Hint, title, turn - kind := appToolKind(event.Tool, event.Service) + kind := appToolKind(event.Tool, event.Service, category) if c.facts.Ask != "" { kind = appwire.ToolAsksFirst } @@ -389,6 +394,7 @@ func (b *appBackend) callFinished(a *appAgent, event session.Event) { // callEnded records a call's end and answers its row. func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event session.Event, title, summary, detail string, ok bool) appwire.TurnTool { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) process := "" if ok && !event.HarnessMade && !event.Refused && !event.RejectedBeforeRun { @@ -399,7 +405,7 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses c := log.calls[event.CallID] if c == nil { c = &appCall{id: event.CallID, tool: event.Tool, args: event.Args, turn: turn} - c.facts.Kind = appToolKind(event.Tool, "") + c.facts.Kind = appToolKind(event.Tool, "", category) if event.CallID != "" { log.calls[event.CallID] = c log.order = append(log.order, event.CallID) @@ -627,7 +633,7 @@ func (b *appBackend) appEntryFacts(a *appAgent, item session.DisplayEntry) appwi facts.MediaAttempt = item.ToolResult.MediaAttempt() var workspace string b.locked(func() { workspace = a.view.Workspace }) - facts.Kind = appToolKind(item.Tool, "") + facts.Kind = appToolKind(item.Tool, "", b.clientToolEventCategory(a, session.Event{Tool: item.Tool})) facts.Input = appToolInput(facts.Kind, item.Tool, item.Args, item.Hint, workspace) if path, adds, dels, ok := tui3.ToolFileChange(item.Tool, item.Args); ok { facts.Files = []appwire.FileChange{{Path: path, Add: adds, Del: dels}} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index b4865d8725..829b3bd308 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1170,6 +1170,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // Harnesses and RunHarness, properties of the machine that the shared // assembly fills for every door. cfg, open := v3Shape(cfg, v3LanesOverWire()) + open = appClientToolsOpener(open) // AND AUTOMATIONS ARE ON, which is the one capability on this list that a // connection does not take away. The store arrives already filled, from the From 9d5b47203173d10a6033815b8d2d8b5b5a201240 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 15:58:34 -0500 Subject: [PATCH 5/7] Change entry for client tools and collection-held conversations Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2334-client-tools.md | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/changes/unreleased/2334-client-tools.md diff --git a/docs/changes/unreleased/2334-client-tools.md b/docs/changes/unreleased/2334-client-tools.md new file mode 100644 index 0000000000..b08d04aa68 --- /dev/null +++ b/docs/changes/unreleased/2334-client-tools.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Apps can attach their own tools to a conversation, and collections keep blank conversations +pr: 2334 +surface: [app, chat] +invalidates: + - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. +--- + +`codeaf app` clients can attach named sets of tools to a conversation with +`agents.tools_set`. The model sees them after every built-in tool, for as long +as that client stays connected. Each call reaches the client as one +`agents.tool_call` event, and the client answers it with `agents.tool_result`. +A disconnect, the deadline (30 seconds by default, at most 120) or the end of the +turn answers the model with an error that says the outcome is unknown. Late or +repeated answers are ignored. A conversation holds at most 4 sets and 64 tools; +built-in tool names are refused, and child tasks never receive client tools. The +hello carries `agents.client_tools`. Conversations held by a remote engine are +refused for now. + +Launch cleanup, the blank-chat sweep and the background sweep now keep a +never-spoken conversation that any collection references. An unreadable +collections database keeps it too. Opening such a conversation by id after a +restart restores its saved details. From d5c4f9f3ef16f134df6afa861a914081adc79a9c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 16:16:53 -0500 Subject: [PATCH 6/7] The client tools manual page is found by its own name Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/client-tools.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md index 50a25c3b0e..944eb3eaca 100644 --- a/internal/manual/chat/client-tools.md +++ b/internal/manual/chat/client-tools.md @@ -1,6 +1,6 @@ -# Tools an app adds to a conversation +# Client tools: tools an app adds to a conversation -## Tools an app adds to a conversation: connection, limits and waiting +## Client tools: connection, limits and waiting An app connected through `codeaf app` can attach its own tools and instructions to one conversation. The model sees each tool's name, description and input From c06d64d9f3366f402f5939d348dc0373aba49ace Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 3 Nov 2026 16:37:27 -0500 Subject: [PATCH 7/7] Client tool registry follows the guard and name laws The registry's sender runs under guard.Go, every registry lock is released from a defer, and a test name no longer spells the old product name. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appclienttools.go | 15 +++++++++++---- cmd/codeaf/appcollectionkeep_test.go | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go index 4b88e61d07..9c52d5a2ac 100644 --- a/cmd/codeaf/appclienttools.go +++ b/cmd/codeaf/appclienttools.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" ) @@ -280,9 +281,7 @@ func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.Cli } id := clientToolCallID(d.agent, call.TurnCall) r := &d.backend.clientTools - r.mu.Lock() - pending, answer, emit := r.beginLocked(ctx, d.agent, id, call) - r.mu.Unlock() + pending, answer, emit := r.begin(ctx, d.agent, id, call) if pending == nil { return answer, nil } @@ -311,7 +310,7 @@ func (r *appClientTools) queueEmission(emit appwire.Emitter, pending *clientTool if r.senderWake == nil { r.senderWake = make(chan struct{}, 1) r.senderDone = make(chan struct{}) - go r.sendEvents(emit) + guard.Go("app/client-tools", func() { r.sendEvents(emit) }) } select { case r.senderWake <- struct{}{}: @@ -348,6 +347,14 @@ func (r *appClientTools) nextEmission() *appwire.AgentsToolCall { return nil } +// begin takes the registry lock for [appClientTools.beginLocked] alone, so a +// panic inside it cannot leave the lock held. +func (r *appClientTools) begin(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.beginLocked(ctx, agent, id, call) +} + // Register before emitting, so even a synchronous client answer finds its waiter. func (r *appClientTools) beginLocked(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { r.pruneLocked(time.Now()) diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go index 2ca6be0486..019c60feef 100644 --- a/cmd/codeaf/appcollectionkeep_test.go +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -217,7 +217,7 @@ func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { } // Contract (g): a restart recovers every identity field from a never-spoken meta. -func TestCollectionHeldConversationFindAndOpenAfterRestart(t *testing.T) { +func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { root, bucket := collectionKeepEnvironment(t) const id = "reserved-agent" dir := writeV3Session(t, bucket, id, "", time.Time{})