From c8a9f3ce3087558da92d1e8abbc2f0c0c83562d5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 11:54:46 -0500 Subject: [PATCH 1/8] Client tools: a client attaches tools to a conversation Carries #2334 from workstreams/main (e9a571c99) onto dev. The workstream door that commit sat beside is left out, and the collection-held conversation half is already on dev (#2475). Co-Authored-By: Claude Mythos 5.2 --- cmd/codeaf/app.go | 4 + cmd/codeaf/appclienttools.go | 507 +++++++++++++ cmd/codeaf/appclienttools_test.go | 719 +++++++++++++++++++ cmd/codeaf/apptools.go | 16 +- cmd/codeaf/engine.go | 1 + docs/changes/unreleased/2334-client-tools.md | 24 + 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 +- 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 +- 27 files changed, 2475 insertions(+), 25 deletions(-) create mode 100644 cmd/codeaf/appclienttools.go create mode 100644 cmd/codeaf/appclienttools_test.go create mode 100644 docs/changes/unreleased/2334-client-tools.md create mode 100644 internal/appwire/client_tools.go create mode 100644 internal/appwire/client_tools_test.go 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/cmd/codeaf/app.go b/cmd/codeaf/app.go index 910808abb0..0a9ba29d60 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -222,6 +222,7 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) + appClientToolsBackend.Store(backend) backend.attachServices(services) // And the press a job's manager makes Merge and Mark ready through, the // job page's own (factory_manager_press.go). @@ -586,6 +587,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 libraryWatch sync.Once libraryInboxMu sync.Mutex pluginPauses map[string]appPluginPause @@ -4671,6 +4673,8 @@ func (b *appBackend) Close() error { if b.leaving != nil { b.leaving() } + b.clientTools.disconnect() + appClientToolsBackend.CompareAndSwap(b, nil) already := false var waitClosed chan struct{} var crews *appCrewRun diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go new file mode 100644 index 0000000000..9c52d5a2ac --- /dev/null +++ b/cmd/codeaf/appclienttools.go @@ -0,0 +1,507 @@ +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/guard" + "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 + pending, answer, emit := r.begin(ctx, d.agent, id, call) + 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{}) + guard.Go("app/client-tools", func() { 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 +} + +// 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()) + 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 c72a17c4ae..9c31b01b42 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1184,6 +1184,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 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. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 897d253df7..48bb7efebf 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -80,6 +80,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 a0f2cced26..10aeb2dda8 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 8f0ea8a99a..a4f4f3b5fd 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -216,6 +216,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) @@ -417,7 +418,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 { diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md new file mode 100644 index 0000000000..944eb3eaca --- /dev/null +++ b/internal/manual/chat/client-tools.md @@ -0,0 +1,33 @@ +# Client tools: tools an app adds to a conversation + +## 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 +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 24bd5fe384..4121e47a26 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 fd5962a190..c1243e1aae 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -364,6 +364,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 2d52f4fbc8..103d00f84c 100644 --- a/internal/session/connect.go +++ b/internal/session/connect.go @@ -565,7 +565,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 @@ -575,6 +577,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 @@ -586,12 +589,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 3113ed686e..ccbeb8115a 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(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 e30855c0e0..116b93d907 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3600,7 +3600,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), @@ -3609,7 +3609,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 @@ -4013,6 +4013,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 fa0e596acd..4b8fd597a8 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -405,6 +405,7 @@ func renderSystemAt(config Config, now time.Time) string { } 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 613d5336df..3d4b1b90b3 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,6 +96,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config + cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -108,6 +109,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { cfg.ApprovalPolicy = a.config.ApprovalPolicy } cfg.System = 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 5b621372ec..361908025e 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. @@ -2567,6 +2569,9 @@ type Config struct { // foremanFloorDoor), on a launch that has a Factory door. Floor FloorDoor + // 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 @@ -2964,21 +2969,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 49418fb9fd..4c9b2ee824 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 331d052254..01688d8f44 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -340,7 +340,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 157cee0cb46b57777b37a6e3240aa83fa3f41f7e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 11:55:47 -0500 Subject: [PATCH 2/8] Client tools: a tool call names the message its turn answers; the answer cutoff is a wait Carries #2346 (b1a02be03) and #2360 (25c6faa2d) from workstreams/main. Co-Authored-By: Claude Mythos 5.2 --- cmd/codeaf/appclienttools.go | 18 +- cmd/codeaf/appclienttools_test.go | 183 ++++++++++- .../2346-client-tool-source-message.md | 16 + .../2360-client-tool-answer-wait-name.md | 11 + docs/design/app-v3/PROTOCOL.md | 21 +- internal/appwire/client_tools.go | 20 +- internal/appwire/client_tools_test.go | 38 ++- internal/appwire/contract_test.go | 10 + internal/session/agent.go | 5 +- internal/session/client_tools.go | 2 + internal/session/client_tools_test.go | 309 +++++++++++++++++- internal/session/resume.go | 3 + internal/session/turn_origin.go | 21 +- 13 files changed, 615 insertions(+), 42 deletions(-) create mode 100644 docs/changes/unreleased/2346-client-tool-source-message.md create mode 100644 docs/changes/unreleased/2360-client-tool-answer-wait-name.md diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go index 9c52d5a2ac..d6f7f6b294 100644 --- a/cmd/codeaf/appclienttools.go +++ b/cmd/codeaf/appclienttools.go @@ -34,7 +34,7 @@ type clientToolPending struct { ctx context.Context done chan struct{} call session.ClientToolCall - deadline time.Time + answerBy time.Time answer session.ClientToolAnswer event *appwire.AgentsToolCall } @@ -293,7 +293,7 @@ func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.Cli } }) 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}) + Call: id, TurnCall: call.TurnCall, SourceMessageID: call.SourceMessage, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) } return r.wait(ctx, id, pending), nil } @@ -340,7 +340,7 @@ func (r *appClientTools) nextEmission() *appwire.AgentsToolCall { for _, pending := range r.pending { event := pending.event pending.event = nil - if event != nil && pending.ctx.Err() == nil && time.Now().Before(pending.deadline) { + if event != nil && pending.ctx.Err() == nil && time.Now().Before(pending.answerBy) { return event } } @@ -364,15 +364,15 @@ func (r *appClientTools) beginLocked(ctx context.Context, agent, id string, call if pending := r.pending[id]; pending != nil { return pending, session.ClientToolAnswer{}, false } - deadline, attached := r.attachedLocked(agent, call.Set, call.Name) + waitMS, 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 + waitMS = call.DeadlineMS } - call.DeadlineMS = deadline - pending := &clientToolPending{ctx: ctx, done: make(chan struct{}), call: call, deadline: time.Now().Add(time.Duration(deadline) * time.Millisecond)} + call.DeadlineMS = waitMS + pending := &clientToolPending{ctx: ctx, done: make(chan struct{}), call: call, answerBy: time.Now().Add(time.Duration(waitMS) * time.Millisecond)} if r.pending == nil { r.pending = make(map[string]*clientToolPending) } @@ -395,7 +395,7 @@ func (r *appClientTools) attachedLocked(agent, setID, name string) (int, bool) { } func (r *appClientTools) wait(ctx context.Context, id string, pending *clientToolPending) session.ClientToolAnswer { - timer := time.NewTimer(time.Until(pending.deadline)) + timer := time.NewTimer(time.Until(pending.answerBy)) defer timer.Stop() var failed session.ClientToolAnswer select { @@ -472,7 +472,7 @@ func (b *appBackend) ClientToolResult(p appwire.ClientToolResultParams) (appwire r.settleLocked(p.Call, pending, clientToolTurnEnded(pending.call.Name)) return appwire.ClientToolResultAck{}, nil } - if !time.Now().Before(pending.deadline) { + if !time.Now().Before(pending.answerBy) { r.settleLocked(p.Call, pending, clientToolTimeout(pending.call)) return appwire.ClientToolResultAck{}, nil } diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go index cb9326c985..41d19415b6 100644 --- a/cmd/codeaf/appclienttools_test.go +++ b/cmd/codeaf/appclienttools_test.go @@ -21,12 +21,159 @@ import ( ) type clientToolEmitter struct { + t *testing.T mu sync.Mutex calls []appwire.AgentsToolCall seen chan appwire.AgentsToolCall answer func(appwire.AgentsToolCall) } +// Source contract (a), (b): real provider calls name the entry returned by the +// app read projection, across a second send, a reopened journal and takeover resume. +func TestClientToolsSourceMessageWireAndRestart(t *testing.T) { + for _, stopped := range []bool{false, true} { + t.Run(fmt.Sprintf("stopped=%t", stopped), func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + entered := make(chan struct{}) + var mu sync.Mutex + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tool := len(request.Tools) > 0 && len(request.Messages) > 0 && request.Messages[len(request.Messages)-1].Role == "user" + mu.Lock() + if tool { + calls++ + } + call := calls + mu.Unlock() + if tool && stopped && call == 1 { + close(entered) + select { + case <-r.Context().Done(): + case <-ctx.Done(): + } + return + } + w.Header().Set("Content-Type", "text/event-stream") + if tool { + fmt.Fprintf(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-source-%d","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"finish_reason":null}]}`+"\n\n", call) + 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) + answer := func(backend *appBackend, emit *clientToolEmitter) { + emit.answer = func(event appwire.AgentsToolCall) { + ack, err := backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Errorf("tool result: %+v %v", ack, err) + } + } + } + answer(b, emitter) + path := filepath.Join(t.TempDir(), "session.jsonl") + cfg := session.Config{Workspace: t.TempDir(), SessionFile: path, Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door} + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + drain := func(events <-chan session.Event) { + t.Helper() + for { + select { + case _, open := <-events: + if !open { + return + } + case <-ctx.Done(): + t.Fatal("turn did not finish") + } + } + } + events, err := agent.Submit(ctx, "first question") + if err != nil { + t.Fatal(err) + } + if stopped { + clientToolAwait(t, entered) + agent.InterruptFor(session.StopByTakeover) + } + drain(events) + messageID := func(conv *session.Agent, text string) string { + t.Helper() + for _, entry := range appEntriesOf(door.agent, conv.Transcript(), time.Time{}, nil) { + if entry.Kind == "user" && entry.Text == text && entry.MessageID != "" { + return entry.MessageID + } + } + t.Fatalf("read omitted message id for %q", text) + return "" + } + firstID := messageID(agent, "first question") + if !stopped { + first := clientToolAwait(t, emitter.seen) + if first.SourceMessageID != firstID { + t.Fatalf("first call=%+v read message=%q", first, firstID) + } + next, err := agent.SubmitMessage(ctx, session.Submission{Text: "second question", ClientID: "second-window"}) + if err != nil { + t.Fatal(err) + } + drain(next) + second := clientToolAwait(t, emitter.seen) + secondID := messageID(agent, "second question") + if second.SourceMessageID != secondID || secondID == firstID { + t.Fatalf("second call=%+v read ids=%q/%q", second, firstID, secondID) + } + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + answer(fresh, freshEmitter) + cfg.ClientTools = freshDoor + reopened, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reopened.Close() }) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.conn.Conv = reopened }) + if got := messageID(reopened, "first question"); got != firstID { + t.Fatalf("reopened read changed message id: %q want %q", got, firstID) + } + if stopped { + resumed, ok := reopened.ResumeStoppedTurn(ctx) + if !ok { + t.Fatal("reopened stopped turn did not resume") + } + drain(resumed) + call := clientToolAwait(t, freshEmitter.seen) + if call.SourceMessageID != firstID { + t.Fatalf("resumed call=%+v read message=%q", call, firstID) + } + } + }) + } +} + // 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) { @@ -192,7 +339,12 @@ func (e *clientToolEmitter) Emit(event string, data any) { e.mu.Lock() e.calls = append(e.calls, call) e.mu.Unlock() - e.seen <- call + select { + case e.seen <- call: + case <-time.After(2 * time.Second): + e.t.Error("client tool event could not be delivered within two seconds") + return + } if e.answer != nil { e.answer(call) } @@ -201,7 +353,7 @@ func (e *clientToolEmitter) Emit(event string, data any) { func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { t.Helper() t.Setenv(home.EnvVar, t.TempDir()) - e := &clientToolEmitter{seen: make(chan appwire.AgentsToolCall, 20)} + e := &clientToolEmitter{t: t, seen: make(chan appwire.AgentsToolCall, 20)} b := newFakeBackend(e, nil) id := "client-agent" transcript := filepath.Join(t.TempDir(), id, "transcript.jsonl") @@ -318,14 +470,15 @@ func clientToolAwait[T any](t *testing.T, done <-chan T) T { return zero } -// Contract (b), (d): fast answers, duplicate dispatches and replay share one event. +// Contract (b), (d), source contract (b): redispatch preserves the source and emits once. func TestClientToolsEventResultAndReplay(t *testing.T) { b, door, emitter := clientToolBackend(t) call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-recorded" 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) { + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) || event.SourceMessageID != call.SourceMessage { t.Fatalf("event shape: %+v", event) } second := clientToolStart(context.Background(), door, call) @@ -349,6 +502,19 @@ func TestClientToolsEventResultAndReplay(t *testing.T) { if len(emitter.calls) != 1 { t.Fatalf("%d events for one call", len(emitter.calls)) } + // A new registry represents a new process, with a different local counter. + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.turn = 1 }) + replayed := clientToolStart(context.Background(), freshDoor, call) + again := clientToolAwait(t, freshEmitter.seen) + if again.Call != event.Call || again.SourceMessageID != event.SourceMessageID || again.Turn != 1 { + t.Fatalf("fresh process changed correlation: %+v / %+v", event, again) + } + if _, err := fresh.ClientToolResult(appwire.ClientToolResultParams{Call: again.Call, Result: json.RawMessage(`true`)}); err != nil { + t.Fatal(err) + } + clientToolAwait(t, replayed) } // Contract (b): the pending entry exists before an emitter can answer synchronously. @@ -503,7 +669,7 @@ func TestClientToolsEndedQueuedCallsAreNotEmitted(t *testing.T) { switch ending { case "deadline": r.mu.Lock() - pending.deadline = time.Now().Add(-time.Second) + pending.answerBy = time.Now().Add(-time.Second) r.mu.Unlock() case "turn end": cancelQueued() @@ -630,13 +796,18 @@ func TestClientToolsAgentLimitsAndScope(t *testing.T) { } } -// Contract (d): a concurrent dispatch joins the actual pending object without emitting. +// Contract (d), source contract (b): pending joins retain the source without emitting. func TestClientToolsPendingJoinAndCancelledAnswer(t *testing.T) { b, door, emitter := clientToolBackend(t) call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-pending" ctx, cancel := context.WithCancel(context.Background()) + defer cancel() done := clientToolStart(ctx, door, call) event := clientToolAwait(t, emitter.seen) + if event.SourceMessageID != call.SourceMessage { + t.Fatalf("pending event lost source: %+v", event) + } r := &b.clientTools r.mu.Lock() first := r.pending[event.Call] diff --git a/docs/changes/unreleased/2346-client-tool-source-message.md b/docs/changes/unreleased/2346-client-tool-source-message.md new file mode 100644 index 0000000000..2676eba4c7 --- /dev/null +++ b/docs/changes/unreleased/2346-client-tool-source-message.md @@ -0,0 +1,16 @@ +--- +kind: added +title: A client tool call names the message its turn answers +pr: 2346 +surface: [app, chat] +invalidates: [] +--- + +`agents.tool_call` carries an optional `source_message_id`: the durable id of +the message the running turn answers, the same string as `Entry.message_id` +on that message's chat entry. It is captured when the turn starts, so it is the +same after an engine restart and on a replayed call. A stopped turn asked again +carries its recorded message. Turns the engine starts itself, including a +carry-on after a quit or close, carry none. The hello feature +`agents.client_tools.source` is advertised with `agents.client_tools`. `turn` +remains the process-local counter. diff --git a/docs/changes/unreleased/2360-client-tool-answer-wait-name.md b/docs/changes/unreleased/2360-client-tool-answer-wait-name.md new file mode 100644 index 0000000000..abb3050079 --- /dev/null +++ b/docs/changes/unreleased/2360-client-tool-answer-wait-name.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: Client tools name their answer cutoff so the leaf-room law reads it as a wait +pr: 2360 +surface: [app] +invalidates: [] +--- + +The pending client-tool answer timestamp is named `answerBy`, and its selected +millisecond limit is named `waitMS`. The client's effective time limit, emitted +`DeadlineMS`, wait timer and expiry checks are unchanged. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 48bb7efebf..44d80a0acf 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -82,8 +82,10 @@ decides which combinations are accepted. ## 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 +The optional features `agents.client_tools` and `agents.client_tools.source` +appear together in `hello.features` exactly when the engine implements the +client-tools interface. The latter guarantees message identity on calls whenever +the running turn answers a message. 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 @@ -122,10 +124,21 @@ the tail after all built-ins. | event | data | |---|---| -| `agents.tool_call` | `{agent, turn, call, turn_call, set, name, args: , deadline_ms}` | +| `agents.tool_call` | `{agent, turn, call, turn_call, source_message_id?, set, name, args: , deadline_ms}` | + +`source_message_id` names the message the running turn answers: the same string +as `Entry.message_id` on that message's chat entry in `agents.read` (and in +`entry.added` when present). It is captured when the turn starts, never changed +by a mid-turn steer or note, and survives journal restarts and call replays. +It is absent when no message can be named exactly. A stopped answer reuses its +recorded message id. Turns the engine starts itself, including ordinary session +wakes, mixed batches and a carry-on after a quit or close, carry none. +With `agents.client_tools.source` advertised, +an absent field means no such message, rather than an older engine. 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 +process-local conversation counter, which resets after restart; +`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 diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go index 589f22fd07..a44a3fb508 100644 --- a/internal/appwire/client_tools.go +++ b/internal/appwire/client_tools.go @@ -8,6 +8,7 @@ import ( ) const FeatureClientTools = "agents.client_tools" +const FeatureClientToolsSource = "agents.client_tools.source" const EventAgentsToolCall = "agents.tool_call" // ClientTooler is optional because only a connected app can answer these tools. @@ -31,14 +32,15 @@ type ClientToolsSetResult struct { } 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"` + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + TurnCall string `json:"turn_call"` + SourceMessageID string `json:"source_message_id,omitempty"` + Set string `json:"set"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` } type ClientToolResultParams struct { @@ -130,7 +132,7 @@ func (p ClientToolResultParams) Validate() error { func withClientToolsFeature(hello Hello, engine Engine) Hello { if _, ok := engine.(ClientTooler); ok { - return withFeature(hello, FeatureClientTools) + return withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) } return hello } diff --git a/internal/appwire/client_tools_test.go b/internal/appwire/client_tools_test.go index d01bed6a61..70f2b779b8 100644 --- a/internal/appwire/client_tools_test.go +++ b/internal/appwire/client_tools_test.go @@ -36,7 +36,7 @@ func validClientToolsParams() ClientToolsSetParams { InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionRead}}} } -// Contract (f): the actual Serve hello advertises only an implemented door. +// Contract (f) and source contract (d): both features accompany the implemented door. func TestClientToolsHelloFeature(t *testing.T) { for _, test := range []struct { engine Engine @@ -53,16 +53,48 @@ func TestClientToolsHelloFeature(t *testing.T) { if err := json.Unmarshal(bytes.Split(out.Bytes(), []byte("\n"))[0], &frame); err != nil { t.Fatal(err) } - found := false + found, source := false, false for _, feature := range frame.Data.Features { found = found || feature == FeatureClientTools + source = source || feature == FeatureClientToolsSource } - if found != test.want { + if found != test.want || source != test.want { t.Fatalf("features=%v want client tools=%v", frame.Data.Features, test.want) } } } +// Source contract (d): an unknown message is omitted, and a known one is exact. +func TestClientToolCallSourceMessageJSON(t *testing.T) { + for _, source := range []string{"", "msg-recorded"} { + raw, err := json.Marshal(AgentsToolCall{SourceMessageID: source}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + value, present := fields["source_message_id"] + if present != (source != "") || present && string(value) != `"msg-recorded"` { + t.Fatalf("source %q encoded as %s", source, raw) + } + } +} + +// Source contract (d): the written feature guarantee and event shape match the wire. +func TestClientToolsSourceProtocol(t *testing.T) { + raw, err := os.ReadFile(protocolDoc) + if err != nil { + t.Fatal(err) + } + for _, phrase := range []string{FeatureClientToolsSource, "source_message_id?", "Entry.message_id", "process-local"} { + if !strings.Contains(string(raw), phrase) { + t.Errorf("protocol omits %q", phrase) + } + } +} + // Contract (b), (h): both typed methods preserve their declared shapes. func TestClientToolsTypedMethods(t *testing.T) { e := &clientToolsEngine{} diff --git a/internal/appwire/contract_test.go b/internal/appwire/contract_test.go index 1c6b32e270..19fad5e978 100644 --- a/internal/appwire/contract_test.go +++ b/internal/appwire/contract_test.go @@ -140,6 +140,7 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { Events map[string]json.RawMessage `json:"events"` Defs map[string]struct { Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` } `json:"$defs"` } if err := json.Unmarshal(out, &doc); err != nil { @@ -148,6 +149,15 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { if len(doc.Methods) != len(Methods()) || len(doc.Events) != len(Events()) { t.Fatalf("schema has %d methods and %d events, want %d and %d", len(doc.Methods), len(doc.Events), len(Methods()), len(Events())) } + // Source contract (d): the event registry exposes the optional durable id. + if got := doc.Defs["AgentsToolCall"].Properties["source_message_id"]["type"]; got != "string" { + t.Fatalf("AgentsToolCall.source_message_id schema type=%v", got) + } + for _, field := range doc.Defs["AgentsToolCall"].Required { + if field == "source_message_id" { + t.Fatal("source_message_id became required") + } + } for _, field := range []string{"invoked", "rejected_before_run", "harness_made", "refused", "media_attempt"} { for _, name := range []string{"TurnTool", "Entry"} { if got := doc.Defs[name].Properties[field]["type"]; got != "boolean" { diff --git a/internal/session/agent.go b/internal/session/agent.go index 4121e47a26..dea13ae91a 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1438,6 +1438,9 @@ type userMessage struct { // leave it empty, and the turn is then stamped with no person message — an // unknown, never a guess at the latest one. answersMessage string + // sourceMessage is the exact recorded message a resumed answer continues. + // Only ResumeStoppedTurn sets it; ordinary notes never inherit the latest message. + sourceMessage string // said is THE PERSON'S OWN WORDS, when what the model reads is not only // them. Empty in every ordinary case, and set where the session writes @@ -2058,7 +2061,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // turn, or one a task or member report opened, has a run and no person // message, which is the truth. A steer that lands later is NOT the opener // and changes nothing here. - turnCtx = withTurnOrigin(turnCtx, turnOrigin{run: runIdentity(turnCtx), turn: openingMessage(user)}) + turnCtx = withTurnOrigin(turnCtx, turnOriginLocked(user, runIdentity(turnCtx))) // AND THE TURN CARRIES THE CREATION CONTEXT the accepting door stated on // its opening message (creation_team.go), only when it stated one: any other // turn leaves the team unknown. Never rewritten from who manages a team now. diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go index 55d4c2afbe..ee710beac6 100644 --- a/internal/session/client_tools.go +++ b/internal/session/client_tools.go @@ -50,6 +50,7 @@ type ClientToolSet struct { type ClientToolCall struct { Set, Name, TurnCall string + SourceMessage string Args json.RawMessage DeadlineMS int } @@ -205,6 +206,7 @@ func (a *Agent) clientTool(set ClientToolSet, spec ClientTool) bare.Tool { } answer, err := a.config.ClientTools.CallClientTool(ctx, ClientToolCall{ Set: set.ID, Name: spec.Name, Args: args, TurnCall: callIDFrom(ctx), DeadlineMS: set.DeadlineMS, + SourceMessage: turnOriginFrom(ctx).answers, }) if err != nil { return err.Error(), true, nil diff --git a/internal/session/client_tools_test.go b/internal/session/client_tools_test.go index 6b4f7fdb52..9f5f1e1d76 100644 --- a/internal/session/client_tools_test.go +++ b/internal/session/client_tools_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -20,6 +22,279 @@ type clientToolsFakeDoor struct { answer ClientToolAnswer } +func clientToolSourceStep(id string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse(id, "app_note", `{}`), nil + } +} + +// Source contract (a): every submission door and a queued follow-up name their own line. +func TestClientToolsSourceSubmissionDoors(t *testing.T) { + for _, kind := range []string{"plain", "message", "composed", "follow-up"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := clientToolSourceStep("first") + if kind == "follow-up" { + first = func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("first")(ctx, messages) + } + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, finishingStep(), clientToolSourceStep("second"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var events, next <-chan Event + var err error + switch kind { + case "plain", "follow-up": + events, err = a.Submit(ctx, "first question") + case "message": + events, err = a.SubmitMessage(ctx, Submission{Text: "first question", ClientID: "another-window"}) + case "composed": + _, events, err = a.SubmitComposed(ctx, Submission{Text: "first question"}) + } + if err != nil { + t.Fatal(err) + } + if kind == "follow-up" { + waitSignal(t, entered, "the first provider step before queuing the follow-up") + next, err = a.FollowUp("second question") + close(release) + if err != nil { + t.Fatal(err) + } + } + collect(t, events) + if next == nil { + next = mustSubmit(t, a, "second question") + } + collect(t, next) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("messages=%v calls=%+v", ids, door.calls) + } + for i, call := range door.calls { + if call.SourceMessage != ids[i] { + t.Errorf("call %d source=%q want %q", i, call.SourceMessage, ids[i]) + } + } + }) + } +} + +// Source contract (b): takeover replay reuses the recorded id without changing crew provenance. +func TestClientToolsSourceStoppedTurnReplay(t *testing.T) { + path := takenOverMidThought(t, func(a *Agent) { a.InterruptFor(StopByTakeover) }) + want := journalPersonIDs(t, path)[0] + door := clientToolsFixture() + var crewOpener string + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + crewOpener = turnOriginFrom(ctx).turn + return clientToolSourceStep("resumed")(ctx, messages) + }, finishingStep(), + }}, func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != want || crewOpener != "" { + t.Fatalf("calls=%+v crew opener=%q want message %q", door.calls, crewOpener, want) + } +} + +// Source contract (b), (d): resuming a legacy line never invents a message id. +func TestClientToolsSourceLegacyStoppedTurn(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + journal := `{"type":"session","version":1,"id":"legacy"}` + "\n" + + `{"type":"message","role":"user","content":"old question"}` + "\n" + + `{"type":"error","error":{"door":"taken over","message":"turn ended: taken over"}}` + "\n" + if err := os.WriteFile(path, []byte(journal), 0600); err != nil { + t.Fatal(err) + } + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("legacy"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("legacy stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("legacy line gained identity: %+v", door.calls) + } +} + +// Source contract (b), (d): quit and close carry-ons never name a source message, +// even when a person opened the interrupted run in the reopened journal. +func TestClientToolsSourceCarryOnReplay(t *testing.T) { + for _, closeKey := range []bool{false, true} { + for _, wake := range []bool{false, true} { + t.Run(fmt.Sprintf("close=%t/wake=%t", closeKey, wake), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + started := make(chan struct{}) + steps := []step{thinkingStep(started)} + if wake { + steps = append([]step{finishingStep()}, steps...) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: steps}, func(c *Config) { c.SessionFile = path }) + t.Cleanup(a.Interrupt) + var events <-chan Event + if wake { + collect(t, mustSubmit(t, a, "original question")) + wakes, stop := a.WatchWakes() + defer stop() + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("initial wake note refused") + } + events = awaitWallStream(t, wakes, 10*time.Second) + } else { + events = mustSubmit(t, a, "original question") + } + waitSignal(t, started, "the interrupted provider step") + a.InterruptFor(StopByClosing) + collect(t, events) + if err := a.Close(); err != nil { + t.Fatal(err) + } + ids := journalPersonIDs(t, path) + if len(ids) != 1 { + t.Fatalf("recorded person messages=%v want one", ids) + } + door := clientToolsFixture() + fresh, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("carry"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := fresh.WatchWakes() + defer stop() + accepted := false + if closeKey { + accepted = fresh.ResumeAfterCloseKey("continuing") + } else { + accepted = fresh.ResumeAfterQuit() + } + if !accepted { + t.Fatal("carry-on refused") + } + finishJobWake(t, wakes) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("carry-on calls=%+v want no source; recorded message=%q", door.calls, ids[0]) + } + }) + } + } +} + +// Source contract (c): only a wake answering one drained person's line names it. +func TestClientToolsSourceWakes(t *testing.T) { + for _, kind := range []string{"note", "one person", "two people", "mixed", "coalesced"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + var a *Agent + ending := func(context.Context, []ai.Message) (*ai.Response, error) { + var queued []userMessage + switch kind { + case "note": + queued = []userMessage{wakeNote("a job finished")} + case "one person": + queued = []userMessage{userText("drained question")} + case "two people": + queued = []userMessage{userText("drained question"), userText("another question")} + case "mixed": + queued = []userMessage{userText("drained question"), wakeNote("a job finished")} + case "coalesced": + queued = []userMessage{wakeNote("a job finished"), wakeNote("another job finished")} + } + for i := range queued { + if queued[i].wake { + queued[i].authored = true + } + } + // Land after the final request, through the same queue the end drain reads. + a.mu.Lock() + a.steering = append(a.steering, queued...) + a.mu.Unlock() + return textResponse("first answer"), nil + } + a, _ = newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("person"), ending, clientToolSourceStep("wake"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := a.WatchWakes() + defer stop() + collect(t, mustSubmit(t, a, "original question")) + finishJobWake(t, wakes) + want := "" + for _, entry := range a.Transcript() { + if kind == "one person" && entry.Text == "drained question" && entry.Person != nil && entry.Person.Composed != nil { + want = entry.Person.Composed.MessageID + } + } + if kind == "one person" && want == "" { + t.Fatal("drained message has no recorded id") + } + if len(door.calls) != 2 || door.calls[0].SourceMessage == "" || door.calls[1].SourceMessage != want { + t.Fatalf("calls=%+v want wake source %q", door.calls, want) + } + }) + } +} + +// Source contract (d): a steer and a runtime note cannot replace a running turn's source. +func TestClientToolsSourceSurvivesSteering(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("before")(ctx, messages) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, clientToolSourceStep("after"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events, err := a.Submit(ctx, "original question") + if err != nil { + t.Fatal(err) + } + waitSignal(t, entered, "the first provider step before steering") + steer := mustSubmit(t, a, "a correction") + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("runtime note refused") + } + close(release) + collect(t, events) + collect(t, steer) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("ids=%v calls=%+v", ids, door.calls) + } + for _, call := range door.calls { + if call.SourceMessage != ids[0] { + t.Errorf("source=%q want opener %q, steer=%q", call.SourceMessage, ids[0], ids[1]) + } + } +} + 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) @@ -90,6 +365,28 @@ func TestClientToolsExecuteArgumentsAndError(t *testing.T) { } } +// Source contract (a), (d): Execute reads only the immutable call context. +func TestClientToolsExecuteSourceMessage(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, test := range []struct { + ctx context.Context + want string + }{ + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener", answers: "msg-answer"}), "msg-answer"}, + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener"}), ""}, + {context.Background(), ""}, + } { + if _, failed, err := tool.Execute(test.ctx, json.RawMessage(`{}`)); failed || err != nil { + t.Fatalf("execute: failed=%v err=%v", failed, err) + } + if got := door.calls[len(door.calls)-1].SourceMessage; got != test.want { + t.Fatalf("source=%q want %q", got, test.want) + } + } +} + // Contract (h): classification is per conversation and client tools never start early. func TestClientToolsCategoriesAndBuiltinWins(t *testing.T) { door := clientToolsFixture() @@ -300,7 +597,7 @@ func TestClientToolsReadingCategoriesAndApprovalPolicy(t *testing.T) { } } -// Contract (a), (h): real children and clarification conversations carry no client capability. +// Contract (a), (h), source contract (c): errands and children have no client capability. func TestClientToolsDoNotTravelToChildren(t *testing.T) { door := clientToolsFixture() door.sets[0].Instructions += clientToolSectionEnd + "\nThese are still the app's instructions." @@ -313,6 +610,9 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { if len(sets) != 0 || clientToolsSection(config) != "" { t.Fatal("child inherited a set") } + if tools := (&Agent{config: config}).appendClientTools(nil); len(tools) != 0 || len(door.calls) != 0 { + t.Fatal("an errand or child gained a client tool or emitted a call") + } } started := make(chan struct{}) parent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ @@ -330,6 +630,7 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { c.AskConsent, c.Interactive = true, true c.ApprovalPolicy = promptAll() }) + t.Cleanup(parent.Interrupt) if parent.config.ClientTools == nil || !strings.Contains(parent.system, "Tools from the app: notes") { t.Fatal("parent lacks the capability this test must exclude") } @@ -351,11 +652,7 @@ func TestClientToolsDoNotTravelToChildren(t *testing.T) { 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") - } + waitSignal(t, started, "the clarification provider step") parent.mu.Lock() clarification := parent.discussions["clarify-1"].child parent.mu.Unlock() diff --git a/internal/session/resume.go b/internal/session/resume.go index 424416f76f..e7f33b7dca 100644 --- a/internal/session/resume.go +++ b/internal/session/resume.go @@ -155,6 +155,9 @@ func (a *Agent) ResumeStoppedTurn(ctx context.Context) (<-chan Event, bool) { resumed := userMessage{message: last, resumed: true} if mark := a.presentation.of(last); mark != nil { resumed.creation = acceptedCreation(mark.Creation) + if mark.Composed != nil { + resumed.sourceMessage = mark.Composed.MessageID + } } events := a.startTurnLocked(ctx, resumed, nil) a.mu.Unlock() diff --git a/internal/session/turn_origin.go b/internal/session/turn_origin.go index 1804318433..98e513e508 100644 --- a/internal/session/turn_origin.go +++ b/internal/session/turn_origin.go @@ -6,26 +6,39 @@ import ( "github.com/Agent-Field/codeaf/internal/teams" ) -// turnOrigin is the two facts that say which turn caused a line of team -// traffic: the run that is writing it and the person's message that OPENED that -// run. It is built once, at the moment a turn starts, and never changed. +// turnOrigin holds the run, the person's message that opened it for team +// traffic, and the message it answers for client tools. It is built once, at +// the moment a turn starts, and never changed. // // IT IS CARRIED ON THE TURN'S CONTEXT AND NEVER READ FROM THE AGENT. A line is // stamped from the context of the call that produced it, or from a copy taken // when a write was queued, so a turn that was abandoned and replaced, a late // callback and a steer that landed mid-turn can never charge their line to the // turn that happens to be current when the write finally runs. The same law as -// [withRunIdentity], one value wider. +// [withRunIdentity], with the message identities beside it. type turnOrigin struct { // run is the turn's run identity, the id its journal records as `usage_run`. run string // turn is the durable id of the person's message that opened the turn, "" // when no person message opened it or none can be named exactly. turn string + // answers is the durable id of the message this turn answers. Resuming an + // answer can name it without changing who opened the run for crew traffic. + answers string } type turnOriginKey struct{} +// turnOriginLocked captures the message being answered before the turn runs. +// A resumed stopped answer reuses its recorded id independently of crew provenance. +func turnOriginLocked(user userMessage, run string) turnOrigin { + origin := turnOrigin{run: run, turn: openingMessage(user), answers: openingMessage(user)} + if user.resumed { + origin.answers = user.sourceMessage + } + return origin +} + // withTurnOrigin hangs origin on ctx for the life of the turn. func withTurnOrigin(ctx context.Context, origin turnOrigin) context.Context { return context.WithValue(ctx, turnOriginKey{}, origin) From 684f0f33408defe201fb8bd75c94e39b9a75dc13 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 11:56:28 -0500 Subject: [PATCH 3/8] Client tools cross the remote door to the connection that set them Carries #2417 (08b6e1eae) from workstreams/main. Co-Authored-By: Claude Mythos 5.2 --- cmd/codeaf/app.go | 1 + cmd/codeaf/appclienttools.go | 21 +- cmd/codeaf/appclienttools_remote.go | 184 ++++ cmd/codeaf/appclienttools_remote_test.go | 577 +++++++++++ cmd/codeaf/engine.go | 3 +- docs/REMOTE.md | 18 + .../unreleased/2417-client-tools-remote.md | 24 + docs/design/app-v3/PROTOCOL.md | 33 +- internal/appwire/client_tools.go | 6 +- internal/manual/chat/client-tools.md | 49 +- internal/remote/callclass.go | 2 + internal/remote/client.go | 8 + internal/remote/clienttools.go | 654 ++++++++++++ internal/remote/clienttools_test.go | 936 ++++++++++++++++++ internal/remote/redial.go | 2 + internal/remote/server.go | 16 +- internal/remote/wire.go | 8 +- internal/session/client_tools.go | 49 +- 18 files changed, 2542 insertions(+), 49 deletions(-) create mode 100644 cmd/codeaf/appclienttools_remote.go create mode 100644 cmd/codeaf/appclienttools_remote_test.go create mode 100644 docs/changes/unreleased/2417-client-tools-remote.md create mode 100644 internal/remote/clienttools.go create mode 100644 internal/remote/clienttools_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 0a9ba29d60..22263822d9 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2054,6 +2054,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. a.crewHeld = a.crewHeld || crewHeld view = a.view }) + b.holdClientTools(a, live) startTitles() b.flushSync(a) b.locked(func() { view = a.view }) diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go index d6f7f6b294..9695a186c0 100644 --- a/cmd/codeaf/appclienttools.go +++ b/cmd/codeaf/appclienttools.go @@ -14,7 +14,6 @@ import ( "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" ) @@ -54,6 +53,7 @@ type appClientTools struct { closed bool senderWake chan struct{} senderDone chan struct{} + publishing map[string]chan struct{} } type appClientToolDoor struct { @@ -127,6 +127,9 @@ func (b *appBackend) SetClientTools(p appwire.ClientToolsSetParams) (appwire.Cli if err := b.clientToolScope(a); err != nil { return result, err } + if agent := b.clientToolRemoteAgent(a); agent != nil { + return b.setRemoteClientTools(a, agent, p) + } held := b.clientToolBuiltinNames(a) r := &b.clientTools r.mu.Lock() @@ -163,15 +166,7 @@ func (b *appBackend) SetClientTools(p appwire.ClientToolsSetParams) (appwire.Cli } 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 { + if agent := b.clientToolRemoteAgent(a); agent != nil && !agent.SupportsClientTools() { return appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") } return nil @@ -273,7 +268,7 @@ func clientToolCallID(agent, provider string) string { func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { if err := ctx.Err(); err != nil { - return clientToolTurnEnded(call.Name), nil + return clientToolContextEnded(ctx, call.Name), nil } var object map[string]json.RawMessage if json.Unmarshal(call.Args, &object) != nil || object == nil { @@ -404,7 +399,7 @@ func (r *appClientTools) wait(ctx context.Context, id string, pending *clientToo case <-timer.C: failed = clientToolTimeout(pending.call) case <-ctx.Done(): - failed = clientToolTurnEnded(pending.call.Name) + failed = clientToolContextEnded(ctx, pending.call.Name) } r.mu.Lock() defer r.mu.Unlock() @@ -469,7 +464,7 @@ func (b *appBackend) ClientToolResult(p appwire.ClientToolResultParams) (appwire return appwire.ClientToolResultAck{}, nil } if pending.ctx.Err() != nil { - r.settleLocked(p.Call, pending, clientToolTurnEnded(pending.call.Name)) + r.settleLocked(p.Call, pending, clientToolContextEnded(pending.ctx, pending.call.Name)) return appwire.ClientToolResultAck{}, nil } if !time.Now().Before(pending.answerBy) { diff --git a/cmd/codeaf/appclienttools_remote.go b/cmd/codeaf/appclienttools_remote.go new file mode 100644 index 0000000000..da45257ccd --- /dev/null +++ b/cmd/codeaf/appclienttools_remote.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "sort" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +const clientToolRemoteWait = 10 * time.Second + +// ClientToolsRemote marks the app backend's forwarding implementation for hello. +func (b *appBackend) ClientToolsRemote() bool { return true } + +func (b *appBackend) clientToolRemoteAgent(a *appAgent) *remote.Agent { + var agent *remote.Agent + b.locked(func() { + if a.view.Location != nil && !a.view.Location.Here { + return + } + if a.live != nil && !a.live.conn.Assembled && !a.live.conn.Lent { + agent, _ = a.live.conn.Conv.(*remote.Agent) + } + }) + return agent +} + +// The agent gate serializes snapshots with holds; the registry lock never spans a call. +func (b *appBackend) setRemoteClientTools(a *appAgent, agent *remote.Agent, p appwire.ClientToolsSetParams) (appwire.ClientToolsSetResult, error) { + result := appwire.ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: []string{}} + r := &b.clientTools + state, err := r.prepareRemoteReplacement(p, b.clientToolBuiltinNames(a)) + if err != nil { + return result, err + } + defer r.finishPublication(p.Agent) + for _, tool := range p.Tools { + result.Tools = append(result.Tools, tool.Name) + } + ctx, cancel := context.WithTimeout(context.Background(), clientToolRemoteWait) + defer cancel() + if _, err := agent.SetClientTools(ctx, state.sets); err != nil { + var refusal *remote.ClientToolsRefusal + if errors.As(err, &refusal) { + return result, appwire.Errorf(appwire.CodeBadParams, "%s", refusal.Reason) + } + if errors.Is(err, remote.ErrClientToolsUnsupported) { + return result, appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") + } + return result, appwire.Errorf(appwire.CodeUnreachable, "%s", err) + } + return result, r.commitRemoteReplacement(p.Agent, state) +} + +func (r *appClientTools) prepareRemoteReplacement(p appwire.ClientToolsSetParams, held map[string]bool) (clientToolAgentSets, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return clientToolAgentSets{}, 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 clientToolAgentSets{}, 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 { + sets = append(sets, clientToolSnapshot(p)) + } + sort.Slice(sets, func(i, j int) bool { return sets[i].ID < sets[j].ID }) + if r.publishing == nil { + r.publishing = make(map[string]chan struct{}) + } + r.publishing[p.Agent] = make(chan struct{}) + return clientToolAgentSets{version: state.version + 1, sets: sets}, nil +} + +func (r *appClientTools) commitRemoteReplacement(agent string, state clientToolAgentSets) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + state.builtins = r.agents[agent].builtins + r.agents[agent] = state + return nil +} + +func (r *appClientTools) finishPublication(agent string) { + r.mu.Lock() + defer r.mu.Unlock() + close(r.publishing[agent]) + delete(r.publishing, agent) +} + +func (r *appClientTools) publication(agent string) <-chan struct{} { + r.mu.Lock() + defer r.mu.Unlock() + return r.publishing[agent] +} + +func (b *appBackend) callRemoteClientTool(ctx context.Context, door *appClientToolDoor, call session.ClientToolCall) (session.ClientToolAnswer, error) { + ctx, cancel := context.WithTimeout(ctx, time.Duration(call.DeadlineMS)*time.Millisecond) + defer cancel() + // Acceptance can arrive before the setter commits the matching app registry. + // Waiting here must still end when the call loses its link or wait. + for { + published := b.clientTools.publication(door.agent) + if published == nil { + break + } + select { + case <-published: + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return clientToolTimeout(call), nil + } + return clientToolContextEnded(ctx, call.Name), nil + } + } + return door.CallClientTool(ctx, call) +} + +func (b *appBackend) holdClientTools(a *appAgent, live *appLive) { + if live.conn.Assembled || live.conn.Lent { + return + } + agent, ok := live.conn.Conv.(*remote.Agent) + if !ok || !agent.SupportsClientTools() { + return + } + door := &appClientToolDoor{backend: b, agent: a.view.ID} + agent.SetClientToolHandler(func(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + return b.callRemoteClientTool(ctx, door, call) + }) + release := live.conn.Release + live.conn.Release = func() { + agent.SetClientToolHandler(nil) + if release != nil { + release() + } + } + _, sets := door.ClientToolSets() + ctx, cancel := context.WithTimeout(context.Background(), clientToolRemoteWait) + defer cancel() + if _, err := agent.SetClientTools(ctx, sets); err != nil { + log.Printf("app/client-tools: could not attach tools for %s: %v", door.agent, err) + } +} + +func clientToolContextEnded(ctx context.Context, name string) session.ClientToolAnswer { + if errors.Is(context.Cause(ctx), remote.ErrClientToolDisconnected) { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", name), IsError: true} + } + return clientToolTurnEnded(name) +} + +// A host process shares one hub across initial, fresh and resumed openings. +func appClientToolsEngineOpener(open func(session.Config) (*session.Agent, error)) (func(session.Config) (*session.Agent, error), *remote.ClientToolHub) { + if appClientToolsBackend.Load() != nil { + return appClientToolsOpener(open), nil + } + hub := &remote.ClientToolHub{} + return func(cfg session.Config) (*session.Agent, error) { + cfg.ClientTools = hub + return open(cfg) + }, hub +} diff --git a/cmd/codeaf/appclienttools_remote_test.go b/cmd/codeaf/appclienttools_remote_test.go new file mode 100644 index 0000000000..e579d9e58e --- /dev/null +++ b/cmd/codeaf/appclienttools_remote_test.go @@ -0,0 +1,577 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolRemoteRig struct { + backend *appBackend + door *appClientToolDoor + emitter *clientToolEmitter + hub *remote.ClientToolHub + held *remote.Session + agent *appAgent + loops []*remote.Loop + file string + root string +} + +func clientToolRemoteBackend(t *testing.T, endpoint string) *clientToolRemoteRig { + t.Helper() + return clientToolRemoteBackendSupport(t, endpoint, true) +} + +func clientToolRemoteBackendSupport(t *testing.T, endpoint string, forwards bool) *clientToolRemoteRig { + t.Helper() + b, door, emitter := clientToolBackend(t) + hub := &remote.ClientToolHub{} + root := t.TempDir() + file := filepath.Join(root, "transcript.jsonl") + if endpoint == "" { + endpoint = "https://example.invalid" + } + engineAgent, err := session.New(session.Config{Workspace: root, SessionFile: file, + Model: "test/model", APIKey: "fixture", BaseURL: endpoint, OneModel: true, + DeterministicNames: true, PromptProfile: "full", ClientTools: hub}) + if err != nil { + t.Fatal(err) + } + engine := &remote.Engine{Agent: engineAgent, SessionFile: file, Workspace: root} + if forwards { + engine.ClientTools = hub + } + held := remote.NewSession(engine, true) + a, err := b.find(door.agent) + if err != nil { + t.Fatal(err) + } + b.locked(func() { a.live = nil }) + rig := &clientToolRemoteRig{backend: b, door: door, emitter: emitter, hub: hub, held: held, agent: a, root: root, file: file} + t.Cleanup(func() { + func() { + a.gate.Lock() + defer a.gate.Unlock() + b.release(a) + }() + for _, loop := range rig.loops { + _ = loop.Close() + } + _ = held.Close() + }) + rig.hold(t) + return rig +} + +func (r *clientToolRemoteRig) hold(t *testing.T) *remote.Loop { + t.Helper() + loop, err := remote.LoopbackAttach(remote.Hello{Session: r.file, Join: true}, remote.AttachOptions{ + Open: func(hello remote.Hello) (*remote.Session, error) { + if !hello.Join || hello.Session != r.file { + t.Errorf("expected a join of %s, got %+v", r.file, hello) + } + return r.held, nil + }, + }) + if err != nil { + t.Fatal(err) + } + r.loops = append(r.loops, loop) + conn := &appConn{Conv: loop.Client.Agent(), Transcript: r.file, Workspace: r.root, Release: func() { _ = loop.Client.Close() }} + r.agent.gate.Lock() + defer r.agent.gate.Unlock() + r.backend.hold(r.agent, conn, r.root) + if r.agent.live.conn.Assembled || r.agent.live.conn.Lent { + t.Fatal("fixture must hold a joined conversation") + } + return loop +} + +// C1, C2: a joined app sends declarations and returns results through the holder's model loop. +func TestClientToolsRemoteJoinedModelRoundTrip(t *testing.T) { + for _, isError := range []bool{false, true} { + t.Run(fmt.Sprintf("error=%t", isError), func(t *testing.T) { + var mu sync.Mutex + var requests []json.RawMessage + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.Unmarshal(body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + first := func() bool { + mu.Lock() + defer mu.Unlock() + first := len(request.Tools) > 0 && len(requests) == 0 + if len(request.Tools) > 0 { + requests = append(requests, body) + } + return first + }() + 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-remote-1","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"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(provider.Close) + rig := clientToolRemoteBackend(t, provider.URL) + if _, err := rig.backend.SetClientTools(clientToolParams(rig.door.agent, "notes", "app_note")); err != nil { + t.Fatal(err) + } + rig.emitter.answer = func(event appwire.AgentsToolCall) { + params := appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "saved": true }`)} + if isError { + text := "no access" + params.Result, params.Error = nil, &text + } + ack, err := rig.backend.ClientToolResult(params) + if err != nil || !ack.Accepted { + t.Errorf("answer: %+v %v", ack, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := rig.loops[0].Client.Agent().Submit(ctx, "use the app note") + if err != nil { + t.Fatal(err) + } + for { + select { + case event, open := <-events: + if !open { + goto finished + } + if event.Kind == session.EventError { + t.Errorf("turn: %v", event.Err) + } + case <-ctx.Done(): + t.Fatal("joined model loop did not finish") + } + } + finished: + call := clientToolAwait(t, rig.emitter.seen) + if call.Call != clientToolCallID(rig.door.agent, "provider-remote-1") || call.TurnCall != "provider-remote-1" || call.SourceMessageID == "" { + t.Fatalf("call identity: %+v", call) + } + matchedMessage := false + for _, entry := range appEntriesOf(rig.door.agent, rig.loops[0].Client.Agent().Transcript(), time.Time{}, nil) { + matchedMessage = matchedMessage || entry.Kind == "user" && entry.Text == "use the app note" && entry.MessageID == call.SourceMessageID + } + if !matchedMessage { + t.Fatalf("call did not name the holder's visible message: %+v", call) + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 2 { + t.Fatalf("model requests: %d", len(requests)) + } + if !strings.Contains(string(requests[0]), "Tools from the app: notes") || !strings.Contains(string(requests[0]), "Use this set.") || !strings.Contains(string(requests[0]), "app_note") { + t.Fatalf("holder belt or instructions omitted: %s", requests[0]) + } + var resultRequest struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(requests[1], &resultRequest); err != nil { + t.Fatal(err) + } + want := `{"saved":true}` + if isError { + want = "notes could not do app_note: no access" + } + found := false + for _, message := range resultRequest.Messages { + found = found || message.Role == "tool" && strings.Contains(clientToolWireText(message.Content), want) + } + if !found { + t.Fatalf("model did not receive %q: %s", want, requests[1]) + } + count := func() int { + rig.emitter.mu.Lock() + defer rig.emitter.mu.Unlock() + return len(rig.emitter.calls) + }() + if count != 1 { + t.Fatalf("one model call emitted %d events", count) + } + }) + } +} + +// C3: losing the remote link settles the app registry and rejects later answers. +func TestClientToolsRemoteAppDropSettlesPending(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + call := clientToolAttach(t, rig.backend, rig.door) + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(context.Background(), call) + answer <- got + }() + event := clientToolAwait(t, rig.emitter.seen) + _ = rig.loops[0].Client.Close() + got := clientToolAwait(t, answer) + if !got.IsError || !strings.HasPrefix(got.Text, "client_unavailable:") { + t.Fatalf("holder did not settle: %+v", got) + } + clientToolAwaitNoPending(t, rig, event.Call) + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("late app answer: %+v %v", ack, err) + } +} + +// C2: transport preserves the app's bounded JSON and its original dropped-byte count. +func TestClientToolsRemoteBoundedResult(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + call := clientToolAttach(t, rig.backend, rig.door) + data := json.RawMessage(`"` + strings.Repeat("é", session.ClientToolOutputMax) + `"`) + rig.emitter.answer = func(event appwire.AgentsToolCall) { + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: data}) + if err != nil || !ack.Accepted { + t.Errorf("bounded result: %+v %v", ack, err) + } + } + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(context.Background(), call) + answer <- got + }() + got := clientToolAwait(t, answer) + want := fmt.Sprintf("[cut: %d bytes dropped]", len(data)-(session.ClientToolOutputMax-1)) + if got.IsError || !strings.HasSuffix(got.Text, want) || strings.Count(got.Text, "[cut:") != 1 { + t.Fatalf("forwarding changed the truncation receipt: %.80s", got.Text[max(0, len(got.Text)-80):]) + } +} + +// C4: every hold sends the current registry, without another tools_set. +func TestClientToolsRemoteReholdPushesSets(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + clientToolAttach(t, rig.backend, rig.door) + func() { + rig.agent.gate.Lock() + defer rig.agent.gate.Unlock() + rig.backend.release(rig.agent) + }() + _ = rig.loops[0].Close() + if _, sets := rig.hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("old link left its sets attached") + } + rig.hold(t) + if _, sets := rig.hub.ClientToolSets(); len(sets) != 1 || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("re-hold snapshot: %+v", sets) + } +} + +// C6, C7: old holders retain their refusal, while relay-held attachments stay cold. +func TestClientToolsRemoteScopeAndElsewhereStorage(t *testing.T) { + b, door, _ := clientToolBackend(t) + a, _ := b.find(door.agent) + b.locked(func() { a.live.conn.Conv = &remote.Agent{} }) + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadScope || refusal.Message != "client tools need a conversation this engine runs" { + t.Fatalf("old holder refusal: %v", err) + } + if _, sets := door.ClientToolSets(); len(sets) != 0 { + t.Fatal("scope refusal changed registry") + } + b.locked(func() { a.view.Location = &appwire.Location{Here: false} }) + if _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")); err != nil { + t.Fatalf("relay-held attachment: %v", err) + } + b.locked(func() { + a.view.Location.Here = true + a.live = nil + }) + if _, sets := door.ClientToolSets(); len(sets) != 1 || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("sets after returning here: %+v", sets) + } + older := clientToolRemoteBackendSupport(t, "", false) + _, err = older.backend.SetClientTools(clientToolParams(older.door.agent, "notes", "app_note")) + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadScope || refusal.Message != "client tools need a conversation this engine runs" { + t.Fatalf("older live holder refusal: %v", err) + } +} + +// C3: a holder's turn-ended frame cancels the app wait before its set's long limit. +func TestClientToolsRemoteAppTurnEnded(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + p := clientToolParams(rig.door.agent, "notes", "app_note") + p.DeadlineMS = 120000 + if _, err := rig.backend.SetClientTools(p); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(ctx, session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "ended-call", Args: json.RawMessage(`{}`)}) + answer <- got + }() + event := clientToolAwait(t, rig.emitter.seen) + cancel() + got := clientToolAwait(t, answer) + if !got.IsError || !strings.Contains(got.Text, "the turn ended") { + t.Fatalf("holder answer: %+v", got) + } + clientToolAwaitNoPending(t, rig, event.Call) + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("late result after holder cancellation: %+v %v", ack, err) + } +} + +func clientToolAwaitNoPending(t *testing.T, rig *clientToolRemoteRig, id string) { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + for { + pending := func() bool { + rig.backend.clientTools.mu.Lock() + defer rig.backend.clientTools.mu.Unlock() + return rig.backend.clientTools.pending[id] != nil + }() + if !pending { + break + } + select { + case <-tick.C: + case <-timer.C: + t.Fatal("ended link or call did not clear the app wait before a late result") + } + } +} + +// C8: the app's real hello advertises forwarding alongside its existing features. +func TestClientToolsRemoteHelloFeature(t *testing.T) { + features, _ := slashCatalogOverTheWire(t) + for _, want := range []string{appwire.FeatureClientTools, appwire.FeatureClientToolsSource, appwire.FeatureClientToolsRemote} { + found := false + for _, feature := range features { + found = found || feature == want + } + if !found { + t.Fatalf("hello omitted %s: %v", want, features) + } + } +} + +// C1: the holder's additional collision checks refuse without changing the app registry. +func TestClientToolsRemoteHolderRefusalIsAtomic(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + rig.hub.ClientToolBuiltins([]string{"app_note"}) + _, err := rig.backend.SetClientTools(clientToolParams(rig.door.agent, "notes", "app_note")) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadParams || !strings.Contains(refusal.Message, "app_note") { + t.Fatalf("holder refusal: %v", err) + } + if _, sets := rig.door.ClientToolSets(); len(sets) != 0 { + t.Fatal("holder refusal changed the app registry") + } + if _, sets := rig.hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("holder refusal changed its own snapshot") + } +} + +// C1: a transport failure is retryable and no registry lock is held on the wire. +func TestClientToolsRemoteTransportFailureLeavesRegistry(t *testing.T) { + b, door, _ := clientToolBackend(t) + surface, holder := remote.Pipe() + t.Cleanup(func() { + _ = surface.Close() + _ = holder.Close() + }) + readRegistry := make(chan struct{}) + go func() { + defer holder.Close() + reader := json.NewDecoder(holder) + var hello remote.Frame + if reader.Decode(&hello) != nil { + return + } + payload, _ := json.Marshal(remote.Welcome{Version: remote.Version, ClientTools: true}) + if json.NewEncoder(holder).Encode(remote.Frame{Kind: "welcome", Payload: payload}) != nil { + return + } + var call remote.Frame + if reader.Decode(&call) != nil || call.Method != remote.MethodClientToolsSet { + return + } + _, _ = door.ClientToolSets() + close(readRegistry) + }() + client, err := remote.Dial(surface, "engine", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = client.Agent() }) + failed := make(chan error, 1) + go func() { + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + failed <- err + }() + err = clientToolAwait(t, failed) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeUnreachable { + t.Fatalf("transport failure: %v", err) + } + clientToolAwait(t, readRegistry) + if _, sets := door.ClientToolSets(); len(sets) != 0 { + t.Fatal("a failed transport committed the app snapshot") + } +} + +// C1, C3: publication orders calls while cancellation and the call wait still apply. +func TestClientToolsRemoteCallDuringPublication(t *testing.T) { + for _, outcome := range []string{"published", "disconnect", "timeout"} { + t.Run(outcome, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + surface, holder := remote.Pipe() + inFlight := make(chan struct{}) + publish, stop, served := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var finish sync.Once + go func() { + defer close(served) + defer holder.Close() + reader := json.NewDecoder(holder) + var hello, call remote.Frame + if reader.Decode(&hello) != nil { + return + } + payload, _ := json.Marshal(remote.Welcome{Version: remote.Version, ClientTools: true}) + if json.NewEncoder(holder).Encode(remote.Frame{Kind: "welcome", Payload: payload}) != nil || reader.Decode(&call) != nil { + return + } + close(inFlight) + <-publish + _ = json.NewEncoder(holder).Encode(remote.Frame{Kind: "result", ID: call.ID, Payload: json.RawMessage(`["app_note"]`)}) + <-stop + }() + client, err := remote.Dial(surface, "engine", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + finish.Do(func() { close(publish) }) + close(stop) + _ = client.Close() + clientToolAwait(t, served) + }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = client.Agent() }) + emitter.answer = func(event appwire.AgentsToolCall) { + _, _ = b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + } + setter := make(chan error, 1) + go func() { + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + setter <- err + }() + clientToolAwait(t, inFlight) + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(context.Canceled) + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := b.callRemoteClientTool(ctx, door, session.ClientToolCall{Set: "notes", Name: "app_note", + TurnCall: "during-publication", Args: json.RawMessage(`{}`), DeadlineMS: 1000}) + answer <- got + }() + if outcome == "published" { + finish.Do(func() { close(publish) }) + if err := clientToolAwait(t, setter); err != nil { + t.Fatal(err) + } + got := clientToolAwait(t, answer) + if got.IsError || got.Text != "true" { + t.Fatalf("call did not wait for registry publication: %+v", got) + } + return + } + if outcome == "disconnect" { + cancel(remote.ErrClientToolDisconnected) + } + got := clientToolAwait(t, answer) + want := "client_timeout:" + if outcome == "disconnect" { + want = "client_unavailable:" + } + if !got.IsError || !strings.HasPrefix(got.Text, want) { + t.Fatalf("call outlived cancellation while publication was in flight: %+v", got) + } + select { + case <-emitter.seen: + t.Fatal("unpublished call reached the app") + default: + } + finish.Do(func() { close(publish) }) + if err := clientToolAwait(t, setter); err != nil { + t.Fatal(err) + } + }) + } +} + +// C4: each opening uses the same host hub, while the app retains its own door. +func TestClientToolsRemoteEngineOpenings(t *testing.T) { + previous := appClientToolsBackend.Swap(nil) + t.Cleanup(func() { appClientToolsBackend.Store(previous) }) + var doors []session.ClientToolDoor + open := func(cfg session.Config) (*session.Agent, error) { + doors = append(doors, cfg.ClientTools) + return nil, nil + } + hostOpen, hub := appClientToolsEngineOpener(open) + if hub == nil { + t.Fatal("a host process has no forwarding hub") + } + for _, file := range []string{"initial", "fresh", "resumed"} { + _, _ = hostOpen(session.Config{SessionFile: file}) + } + for _, door := range doors { + if door != hub { + t.Fatal("an opening lost the conversation's hub") + } + } + b, _, _ := clientToolBackend(t) + appClientToolsBackend.Store(b) + appOpen, appHub := appClientToolsEngineOpener(open) + if appHub != nil { + t.Fatal("the app replaced its own registry with a host hub") + } + _, _ = appOpen(session.Config{SessionFile: "app-conversation"}) + if _, ok := doors[len(doors)-1].(*appClientToolDoor); !ok { + t.Fatal("the app lost its existing conversation door") + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 9c31b01b42..85a113b631 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1184,7 +1184,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) + open, clientTools := appClientToolsEngineOpener(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 @@ -1262,6 +1262,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string EntryNotice: func(file string) string { return v3UpgradeNotice(v3PlaceOf(file, workspace).Dir, true) }, + ClientTools: clientTools, TeamsPaired: teamPaired, SyncService: chatSyncService(), ReleaseOnLeave: func(current remote.WrappedAgent) bool { diff --git a/docs/REMOTE.md b/docs/REMOTE.md index d181805772..c283d68c0a 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -76,6 +76,24 @@ Frame gzip is that shape: an old engine ignores the offer and answers plain JSON a new engine sends plain JSON unless the surface offered gzip. This does not negotiate protocol semantics down — a version mismatch is still refused. +Client tools use the same version-22 rule: `Welcome.clientTools` advertises +`ClientTools.Set` (`{owner, sets}`, one connection's whole snapshot) and +`ClientTool.Answer`. Each client mints one random owner token with at least +128 bits of entropy and carries it on every snapshot (26–256 bytes). A snapshot +from a new connection with the same token moves its sets and ends the old +owner's calls; a different token retains the ordinary collision checks. The old +connection's later departure cannot remove the new owner's sets. +Only a connection that attaches sets receives `clientToolCall` and +`clientToolEnded` frames; neither frame requires a reply. Calls and answers stay +with that connection, and leaving removes its sets and settles its waits. +Watching connections cannot attach or answer tools. A roaming client resends +its last acknowledged snapshot after redial only onto the same conversation, +using the welcome's transcript identity (or live instance when unnamed). A +changed conversation forgets that snapshot. One sender per owner skips queued +calls whose wait ended, so a stuck write blocks only that owner's sender. +Client requests share the ordinary call/write path and its failure behavior. +A holder without the flag receives none of these new calls. + **Why the handshake happens before the TUI starts.** Everything that might ask the person a question happens on a plain terminal: ssh's passphrase prompt, its unknown-host-key question, and this refusal. A TUI that came up first would diff --git a/docs/changes/unreleased/2417-client-tools-remote.md b/docs/changes/unreleased/2417-client-tools-remote.md new file mode 100644 index 0000000000..9d3a54c23a --- /dev/null +++ b/docs/changes/unreleased/2417-client-tools-remote.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Client tools reach conversations held by another codeaf or on another computer +pr: 2417 +surface: [app, remote, chat] +invalidates: + - The app answered bad_scope to agents.tools_set for a conversation another codeaf host held or one held on another computer; it now forwards the tools when the holder supports it and stores them for a chat held elsewhere. +--- + +**Client tools across the remote door.** When the app joins a conversation that +another codeaf host already holds, `agents.tools_set` sends the agent's sets to +that holder. Its calls come back as `agents.tool_call` on the app connection +that set them, and `agents.tool_result` answers go back the same way. The app +pushes the sets again every time it joins the conversation, and a roaming +connection re-sends them after a redial. + +A connection that leaves loses its sets, and its pending calls end at once with +the existing disconnected answer. A call nobody answers still ends at its wait. +Two connections on one conversation keep separate sets and never receive each +other's calls. + +A chat held on another computer keeps its sets until it runs here again. The app +hello adds `agents.client_tools.remote`. A holder from an older build is still +answered with `bad_scope`, and the remote protocol version is unchanged. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 44d80a0acf..37691baeae 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -85,11 +85,23 @@ decides which combinations are accepted. The optional features `agents.client_tools` and `agents.client_tools.source` appear together in `hello.features` exactly when the engine implements the client-tools interface. The latter guarantees message identity on calls whenever -the running turn answers a message. Sets belong to this +the running turn answers a message. `agents.client_tools.remote` additionally +advertises forwarding through a joined conversation's remote connection when +its holder supports it. 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. +instructions. Within the same app connection, every join or re-join pushes the +conversation's current sets to its holder; a roaming link resends them after +redial only when the welcome identifies the same conversation. A changed +conversation forgets the old snapshot and ends its calls. A returning owner's +random token moves its sets to the replacement connection even while the old +connection remains attached; other owners still face collision checks. A +conversation returning to this engine uses the registry directly. +If another computer holds the chat (`Location.Here == false`), attachment still +validates and stores the sets without opening the conversation. They apply +when this engine runs it again after "Continue here" or take, without a second +`agents.tools_set`. | method | params | result | |---|---|---| @@ -117,8 +129,13 @@ 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`. +Unknown agents answer `not_found`. A joined holder without forwarding support +answers `bad_scope`: `client tools need a conversation this engine runs`. +Holder refusals answer `bad_params` with the holder's message; a failed or timed +out link answers `unreachable`, leaving the app registry unchanged for retry. +Different remote connections keep separate sets. The aggregate limits count +all of them, tool-name collisions are refused atomically, and only the +connection that owns a set can receive or answer its calls. Tools and instructions update at the next step boundary, with client tools at the tail after all built-ins. @@ -157,7 +174,13 @@ A pending answer returns `accepted: true`; unknown, late or duplicate ids return 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: +engine retry. + +For forwarded calls, a disconnect observed by the holder immediately removes +that connection's sets and settles its waits. A link loss the holder cannot +observe ends at the set's `deadline_ms`. The app drops the pending entry when +its link ends or the holder reports that the call ended; later results return +`accepted: false`. Failure text is: - A stale call after removal or disconnection: `client_unavailable: the app that provides is not connected; nothing was done.` diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go index a44a3fb508..e3637496df 100644 --- a/internal/appwire/client_tools.go +++ b/internal/appwire/client_tools.go @@ -9,6 +9,7 @@ import ( const FeatureClientTools = "agents.client_tools" const FeatureClientToolsSource = "agents.client_tools.source" +const FeatureClientToolsRemote = "agents.client_tools.remote" const EventAgentsToolCall = "agents.tool_call" // ClientTooler is optional because only a connected app can answer these tools. @@ -132,7 +133,10 @@ func (p ClientToolResultParams) Validate() error { func withClientToolsFeature(hello Hello, engine Engine) Hello { if _, ok := engine.(ClientTooler); ok { - return withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) + hello = withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) + if remote, ok := engine.(interface{ ClientToolsRemote() bool }); ok && remote.ClientToolsRemote() { + hello = withFeature(hello, FeatureClientToolsRemote) + } } return hello } diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md index 944eb3eaca..163fadb4fa 100644 --- a/internal/manual/chat/client-tools.md +++ b/internal/manual/chat/client-tools.md @@ -1,33 +1,36 @@ # Client tools: tools an app adds to a conversation -## Client tools: connection, limits and waiting +## Client tools: connection, remote holders, 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. +An app connected through `codeaf app` can attach tools and instructions to one +conversation. The model sees their names, descriptions and schemas; the app +checks arguments and answers calls. Child tasks, workers and clarification +conversations do not receive them. -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. +Attaching tools does not open the conversation or start a turn. Changes apply +at the next model step. Each conversation allows 4 sets, 32 tools per set and +64 tools total. Replacing a set replaces its tools and instructions; an empty set +removes it. Tool names must be unique and cannot reuse engine tool names. -A conversation held by a collection is kept even before its first message; -it can be reopened by id after a restart. +A collection keeps a conversation before its first message; reopen it 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. +underscores, dots or hyphens, up to 64 characters. Tool names exclude dots and +hyphens. Descriptions take 1–2048 bytes; schemas are JSON objects with +`type: "object"`, up to 16 KiB. Instructions allow 4096 bytes per set. Tools +declare `read`, `edit` or `create`; reading-only contexts refuse changes. +The usual approval rules 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. +The wait defaults to 30 seconds; apps can choose 1–120 seconds per set. +120 seconds is the maximum. Stopping the turn ends the wait. Timeout or +disconnect leaves the outcome unknown: check before trying again. +codeaf does not retry calls automatically. Results are compact JSON, cut at 64 KiB with the dropped-byte count. Error answers take 1–4096 bytes. + +Joined holders forward tools when supported; older holders refuse attachment. +Reconnect restores sets only to the same conversation. A chat held on another +computer stores sets until "Continue here" or take. Each connection owns its +tools and calls. Link loss ends calls when observed or at their wait limit; +later answers are refused. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 9dcd203e48..e9dc1637ba 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,6 +146,8 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { + case MethodClientToolsSet, MethodClientToolAnswer: + return classAct case methodFactoryStatus, MethodAppTasks, MethodAppTask, MethodAppProgramActions, MethodAppTaskRouting, MethodAppTaskRoutingOptions, MethodAppQuickTasks, MethodPing, MethodCheckWork, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodSpendRail, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, diff --git a/internal/remote/client.go b/internal/remote/client.go index 5f8729a085..1fd84511a4 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -90,6 +90,8 @@ const callDeadline = 10 * time.Second // it has to be: the surface asks synchronous getters from its update loop while // a turn's events are arriving on the reader. type Client struct { + clientTools clientToolLink + savingNotices []string // host keeps the typed destination as the fallback until the window chooses @@ -304,6 +306,8 @@ func newClient(host string, hello Hello) *Client { } hello.Encodings = []string{frameEncodingGzip} return &Client{ + clientTools: clientToolLink{owner: rand.Text()}, + host: strings.TrimSpace(host), hello: hello, calls: map[uint64]chan result{}, @@ -751,6 +755,7 @@ func (c *Client) Close() error { c.closing = true conn := c.conn c.mu.Unlock() + c.endClientToolCalls() // A CLOSE ENDS THE ROAMING TOO, and it has to end it now rather than at the // end of whatever backoff the redial loop is sitting in: the person quit, // and a client that went on dialling a machine nobody is watching would be @@ -885,6 +890,8 @@ func (c *Client) read() { return } switch frame.Kind { + case clientToolCallKind, clientToolEndedKind: + c.clientToolFrame(frame) case "observed", "observerClosed": c.observerFrame(frame) case "sideAskDelta": @@ -1062,6 +1069,7 @@ func (c *Client) streamLocked(id uint64) *stream { // a channel left open would leave the surface spinning on a turn nobody is // running. func (c *Client) bury(cause error) { + c.endClientToolCalls() c.closeObservers() c.mu.Lock() if c.dead != nil { diff --git a/internal/remote/clienttools.go b/internal/remote/clienttools.go new file mode 100644 index 0000000000..e57f5ce032 --- /dev/null +++ b/internal/remote/clienttools.go @@ -0,0 +1,654 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Client tool methods ride the existing connection without opening a stream. +const ( + MethodClientToolsSet = "ClientTools.Set" + MethodClientToolAnswer = "ClientTool.Answer" + clientToolCallKind = "clientToolCall" + clientToolEndedKind = "clientToolEnded" + clientToolOwnerMin = 26 + clientToolOwnerMax = 256 +) + +// ErrClientToolsUnsupported means no forwarding capability was advertised. +var ErrClientToolsUnsupported = errors.New("the holder does not forward client tools") + +// ErrClientToolDisconnected distinguishes a lost connection from a stopped turn. +var ErrClientToolDisconnected = errors.New("the client tool connection ended") + +// ClientToolsRefusal is a holder's atomic refusal of a declaration snapshot. +type ClientToolsRefusal struct{ Reason string } + +func (e *ClientToolsRefusal) Error() string { return e.Reason } + +type clientToolCallWire struct { + Call string `json:"call"` + Set string `json:"set"` + Name string `json:"name"` + TurnCall string `json:"turn_call"` + SourceMessage string `json:"source_message,omitempty"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` +} + +type clientToolAnswerWire struct { + Call string `json:"call"` + Text string `json:"text"` + IsError bool `json:"is_error"` +} + +type clientToolSetsWire struct { + Owner string `json:"owner"` + Sets []session.ClientToolSet `json:"sets"` +} + +type clientToolEndedWire struct { + Call string `json:"call"` +} + +type clientToolAck struct { + Accepted bool `json:"accepted"` +} + +type clientToolWait struct { + ctx context.Context + owner *server + call session.ClientToolCall + answerBy time.Time + done chan struct{} + answer session.ClientToolAnswer + sent bool +} + +type clientToolOwner struct { + token string + sets []session.ClientToolSet + wake chan struct{} + ended map[string]bool +} + +// ClientToolHub owns one conversation's declarations and routes by connection. +// Its zero value is ready to use; snapshots remain immutable after publication. +type ClientToolHub struct { + mu sync.Mutex + owners map[*server]*clientToolOwner + builtins map[string]bool + version uint64 + sets []session.ClientToolSet + pending map[string]*clientToolWait + sequence uint64 +} + +var _ session.ClientToolDoor = (*ClientToolHub)(nil) + +// ClientToolSets returns the merged snapshot sorted by set id and first tool name. +func (h *ClientToolHub) ClientToolSets() (uint64, []session.ClientToolSet) { + h.mu.Lock() + defer h.mu.Unlock() + return h.version, h.sets +} + +// ClientToolBuiltins includes deferred engine tools in collision checks. +func (h *ClientToolHub) ClientToolBuiltins(names []string) { + h.mu.Lock() + defer h.mu.Unlock() + h.builtins = make(map[string]bool, len(names)) + for _, name := range names { + h.builtins[name] = true + } +} + +func cloneClientToolSets(sets []session.ClientToolSet) []session.ClientToolSet { + copySets := make([]session.ClientToolSet, len(sets)) + copy(copySets, sets) + for i := range copySets { + copySets[i].Tools = append([]session.ClientTool(nil), sets[i].Tools...) + for j := range copySets[i].Tools { + copySets[i].Tools[j].InputSchema = bytes.Clone(sets[i].Tools[j].InputSchema) + } + if copySets[i].DeadlineMS == 0 { + copySets[i].DeadlineMS = session.ClientToolDefaultDeadlineMS + } + } + return copySets +} + +func (h *ClientToolHub) replace(owner *server, token string, sets []session.ClientToolSet) ([]string, error) { + h.mu.Lock() + defer h.mu.Unlock() + if owner.clientToolsGone.Load() { + return nil, ErrClientToolDisconnected + } + if len(token) < clientToolOwnerMin || len(token) > clientToolOwnerMax { + return nil, fmt.Errorf("client tools need an owner token of %d–%d bytes", clientToolOwnerMin, clientToolOwnerMax) + } + if state := h.owners[owner]; state != nil && state.token != token { + return nil, errors.New("client tool owner token cannot change on a connection") + } + names := make(map[string]bool) + ids := make(map[string]bool) + setCount, toolCount := len(sets), 0 + for other, state := range h.owners { + if other == owner || state.token == token { + continue + } + setCount += len(state.sets) + for _, set := range state.sets { + toolCount += len(set.Tools) + for _, tool := range set.Tools { + names[tool.Name] = true + } + } + } + attached := []string{} + for _, set := range sets { + if err := session.ValidateClientToolSet(set); err != nil { + return nil, err + } + if ids[set.ID] { + return nil, fmt.Errorf("duplicate set id %q", set.ID) + } + ids[set.ID] = true + toolCount += len(set.Tools) + for _, tool := range set.Tools { + if h.builtins[tool.Name] || session.ActionCategoryForTool(tool.Name) != session.ActionWork { + return nil, fmt.Errorf("%s is a built-in engine tool", tool.Name) + } + if names[tool.Name] { + return nil, fmt.Errorf("%s is already in another set", tool.Name) + } + names[tool.Name] = true + attached = append(attached, tool.Name) + } + } + if setCount > session.ClientToolMaxSets || toolCount > session.ClientToolMaxPerAgent { + return nil, fmt.Errorf("an agent allows at most %d sets and %d client tools", session.ClientToolMaxSets, session.ClientToolMaxPerAgent) + } + if h.owners == nil { + h.owners = make(map[*server]*clientToolOwner) + } + for other, state := range h.owners { + if other != owner && state.token == token { + other.clientToolsGone.Store(true) + h.dropLocked(other) + } + } + state := h.owners[owner] + if state == nil { + state = &clientToolOwner{token: token, wake: make(chan struct{}, 1), ended: make(map[string]bool)} + h.owners[owner] = state + guard.Go("remote/client-tools-sender", func() { h.sendCalls(owner, state) }) + } + state.sets = cloneClientToolSets(sets) + h.snapshotLocked() + return attached, nil +} + +func (h *ClientToolHub) snapshotLocked() { + var sets []session.ClientToolSet + for _, state := range h.owners { + sets = append(sets, state.sets...) + } + sort.Slice(sets, func(i, j int) bool { + if sets[i].ID != sets[j].ID { + return sets[i].ID < sets[j].ID + } + return clientToolFirstName(sets[i]) < clientToolFirstName(sets[j]) + }) + h.sets = sets + h.version++ +} + +func clientToolFirstName(set session.ClientToolSet) string { + if len(set.Tools) == 0 { + return "" + } + return set.Tools[0].Name +} + +func clientToolDisconnected(name string) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", name), IsError: true} +} + +func clientToolWaitEnded(call session.ClientToolCall, timedOut bool) session.ClientToolAnswer { + if timedOut { + 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} + } + return session.ClientToolAnswer{Text: fmt.Sprintf("the turn ended before the app answered %s; the outcome is unknown, so check before trying again.", call.Name), IsError: true} +} + +func (h *ClientToolHub) settleLocked(id string, pending *clientToolWait, answer session.ClientToolAnswer, answered bool) bool { + if h.pending[id] != pending { + return false + } + delete(h.pending, id) + pending.answer = answer + if state := h.owners[pending.owner]; state != nil && pending.sent && !answered && !pending.owner.clientToolsGone.Load() { + state.ended[id] = true + h.wakeLocked(state) + } + close(pending.done) + return true +} + +func (h *ClientToolHub) drop(owner *server) { + h.mu.Lock() + defer h.mu.Unlock() + h.dropLocked(owner) +} + +func (h *ClientToolHub) dropLocked(owner *server) { + if state := h.owners[owner]; state != nil { + delete(h.owners, owner) + close(state.wake) + h.snapshotLocked() + } + for id, pending := range h.pending { + if pending.owner == owner { + h.settleLocked(id, pending, clientToolDisconnected(pending.call.Name), false) + } + } +} + +// CallClientTool waits for only the connection that attached this declaration. +func (h *ClientToolHub) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if ctx.Err() != nil { + return clientToolWaitEnded(call, false), nil + } + id, pending := h.begin(ctx, call) + if pending == nil { + return session.ClientToolUnavailable(call.Name), nil + } + timer := time.NewTimer(time.Until(pending.answerBy)) + defer timer.Stop() + timedOut := false + select { + case <-pending.done: + return pending.answer, nil + case <-timer.C: + timedOut = true + case <-ctx.Done(): + } + return h.endWait(id, pending, timedOut), nil +} + +func (h *ClientToolHub) begin(ctx context.Context, call session.ClientToolCall) (string, *clientToolWait) { + h.mu.Lock() + defer h.mu.Unlock() + var owner *server + for candidate, state := range h.owners { + for _, set := range state.sets { + if set.ID != call.Set { + continue + } + for _, tool := range set.Tools { + if tool.Name == call.Name { + owner, call.DeadlineMS = candidate, set.DeadlineMS + } + } + } + } + if owner == nil || owner.clientToolsGone.Load() { + return "", nil + } + h.sequence++ + id := fmt.Sprintf("ct_remote_%x", h.sequence) + pending := &clientToolWait{ctx: ctx, owner: owner, call: call, done: make(chan struct{}), + answerBy: time.Now().Add(time.Duration(call.DeadlineMS) * time.Millisecond)} + if h.pending == nil { + h.pending = make(map[string]*clientToolWait) + } + h.pending[id] = pending + h.wakeLocked(h.owners[owner]) + return id, pending +} + +func (h *ClientToolHub) endWait(id string, pending *clientToolWait, timedOut bool) session.ClientToolAnswer { + h.mu.Lock() + defer h.mu.Unlock() + h.settleLocked(id, pending, clientToolWaitEnded(pending.call, timedOut), false) + return pending.answer +} + +func (h *ClientToolHub) wakeLocked(state *clientToolOwner) { + select { + case state.wake <- struct{}{}: + default: + } +} + +// Each connection has one sender. Calls waiting behind a blocked write live +// only in pending, so their waits remove both their arguments and queued work. +func (h *ClientToolHub) sendCalls(owner *server, state *clientToolOwner) { + for range state.wake { + for { + sent, err := h.sendNext(owner, state) + if err != nil { + owner.dropClientTools() + return + } + if !sent { + break + } + } + } +} + +func (h *ClientToolHub) sendNext(owner *server, state *clientToolOwner) (bool, error) { + owner.write.Lock() + defer owner.write.Unlock() + // Check after taking the writer: another frame may have held it past the wait. + frame, ok := h.nextEmission(owner, state) + if !ok { + return false, nil + } + return true, owner.sendLocked(frame) +} + +func (h *ClientToolHub) nextEmission(owner *server, state *clientToolOwner) (Frame, bool) { + h.mu.Lock() + defer h.mu.Unlock() + if h.owners[owner] != state || owner.clientToolsGone.Load() { + return Frame{}, false + } + for id := range state.ended { + delete(state.ended, id) + return Frame{Kind: clientToolEndedKind, Payload: mustJSON(clientToolEndedWire{Call: id})}, true + } + for id, pending := range h.pending { + if pending.owner != owner || pending.sent { + continue + } + if pending.ctx.Err() != nil || !time.Now().Before(pending.answerBy) { + h.settleLocked(id, pending, clientToolWaitEnded(pending.call, pending.ctx.Err() == nil), false) + continue + } + pending.sent = true + call := pending.call + wire := clientToolCallWire{Call: id, Set: call.Set, Name: call.Name, TurnCall: call.TurnCall, + SourceMessage: call.SourceMessage, Args: call.Args, DeadlineMS: call.DeadlineMS} + return Frame{Kind: clientToolCallKind, Payload: mustJSON(wire)}, true + } + return Frame{}, false +} + +func (h *ClientToolHub) answer(owner *server, wire clientToolAnswerWire) bool { + h.mu.Lock() + defer h.mu.Unlock() + pending := h.pending[wire.Call] + if pending == nil || pending.owner != owner { + return false + } + if owner.clientToolsGone.Load() { + h.settleLocked(wire.Call, pending, clientToolDisconnected(pending.call.Name), false) + return false + } + if pending.ctx.Err() != nil { + h.settleLocked(wire.Call, pending, clientToolWaitEnded(pending.call, false), false) + return false + } + if !time.Now().Before(pending.answerBy) { + h.settleLocked(wire.Call, pending, clientToolWaitEnded(pending.call, true), false) + return false + } + // The app already bounded this text; cutting again would replace its byte count. + return h.settleLocked(wire.Call, pending, session.ClientToolAnswer{Text: wire.Text, IsError: wire.IsError}, true) +} + +func (s *server) dropClientTools() { + s.clientToolsGone.Store(true) + if s.session != nil && s.session.engine != nil && s.session.engine.ClientTools != nil { + s.session.engine.ClientTools.drop(s) + } +} + +func (s *server) invokeClientTools(call Frame) (json.RawMessage, error) { + hub := s.session.engine.ClientTools + if hub == nil { + return nil, ErrClientToolsUnsupported + } + switch call.Method { + case MethodClientToolsSet: + snapshot, err := arg[clientToolSetsWire](call) + if err != nil { + return nil, err + } + names, err := hub.replace(s, snapshot.Owner, snapshot.Sets) + return mustJSON(names), err + case MethodClientToolAnswer: + answer, err := arg[clientToolAnswerWire](call) + if err != nil { + return nil, err + } + return mustJSON(clientToolAck{Accepted: hub.answer(s, answer)}), nil + } + return nil, errors.New("unknown client tool method") +} + +type clientToolIncoming struct { + cancel context.CancelCauseFunc +} + +type clientToolLink struct { + setMu sync.Mutex + owner string + sets []session.ClientToolSet + attached bool + welcome Welcome + + handler func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) + incoming map[string]*clientToolIncoming +} + +// SupportsClientTools reports the welcome capability without a wire call. +func (a *Agent) SupportsClientTools() bool { + if a == nil || a.c == nil { + return false + } + return a.c.Welcome().ClientTools +} + +// SetClientTools atomically replaces this connection's entire snapshot. +func (a *Agent) SetClientTools(ctx context.Context, sets []session.ClientToolSet) ([]string, error) { + if !a.SupportsClientTools() { + return nil, ErrClientToolsUnsupported + } + c := a.c + c.clientTools.setMu.Lock() + defer c.clientTools.setMu.Unlock() + sets = cloneClientToolSets(sets) + welcome := c.Welcome() + payload, answered, err := c.callAnswered(ctx, MethodClientToolsSet, + clientToolSetsWire{Owner: c.clientTools.owner, Sets: sets}, callDeadline) + if err != nil { + if answered && c.clientToolHolderAnswered() { + return nil, &ClientToolsRefusal{Reason: err.Error()} + } + return nil, err + } + var names []string + if err := json.Unmarshal(payload, &names); err != nil { + return nil, err + } + if !c.rememberClientTools(sets, welcome) { + return nil, ErrClientToolDisconnected + } + return names, nil +} + +func (c *Client) clientToolHolderAnswered() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.dead == nil && !c.reconnecting && !c.closing +} + +// Durable transcript identity survives a holder restart. Without a transcript, +// the live instance is the only conversation identity the welcome supplies. +func clientToolConversationChanged(left, now Welcome) bool { + if left.SessionFile != "" { + return conversationSwitched(left.SessionFile, now.SessionFile) + } + return left.SessionInstance != "" && left.SessionInstance != now.SessionInstance +} + +func (c *Client) rememberClientTools(sets []session.ClientToolSet, welcome Welcome) bool { + c.mu.Lock() + defer c.mu.Unlock() + if clientToolConversationChanged(welcome, c.welcome) { + c.forgetClientToolsLocked() + return false + } + c.clientTools.sets, c.clientTools.attached = sets, true + c.clientTools.welcome = Welcome{SessionFile: welcome.SessionFile, SessionInstance: welcome.SessionInstance} + return true +} + +func (c *Client) forgetClientToolsLocked() { + c.clientTools.sets = nil + c.clientTools.attached = false + c.clientTools.welcome = Welcome{} + c.endClientToolCallsLocked() +} + +// SetClientToolHandler replaces the one handler and cancels its previous calls. +// Passing nil releases the handler when a surface stops holding the connection. +func (a *Agent) SetClientToolHandler(handler func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error)) { + if a == nil || a.c == nil { + return + } + c := a.c + c.mu.Lock() + defer c.mu.Unlock() + c.endClientToolCallsLocked() + c.clientTools.handler = handler +} + +func (c *Client) endClientToolCalls() { + c.mu.Lock() + defer c.mu.Unlock() + c.endClientToolCallsLocked() +} + +func (c *Client) endClientToolCallsLocked() { + for id, incoming := range c.clientTools.incoming { + incoming.cancel(ErrClientToolDisconnected) + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) endClientToolCall(id string) { + c.mu.Lock() + defer c.mu.Unlock() + if incoming := c.clientTools.incoming[id]; incoming != nil { + incoming.cancel(context.Canceled) + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) beginClientToolCall(wire clientToolCallWire) (context.Context, *clientToolIncoming, func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error)) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closing || c.dead != nil || c.reconnecting || c.clientTools.incoming[wire.Call] != nil { + return nil, nil, nil + } + ctx, cancel := context.WithCancelCause(context.Background()) + incoming := &clientToolIncoming{cancel: cancel} + if c.clientTools.incoming == nil { + c.clientTools.incoming = make(map[string]*clientToolIncoming) + } + c.clientTools.incoming[wire.Call] = incoming + return ctx, incoming, c.clientTools.handler +} + +func (c *Client) clientToolCallActive(id string, incoming *clientToolIncoming) bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.clientTools.incoming[id] == incoming +} + +func (c *Client) finishClientToolCall(id string, incoming *clientToolIncoming) { + c.mu.Lock() + defer c.mu.Unlock() + if c.clientTools.incoming[id] == incoming { + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) clientToolFrame(frame Frame) { + if frame.Kind == clientToolEndedKind { + var ended clientToolEndedWire + if json.Unmarshal(frame.Payload, &ended) == nil { + c.endClientToolCall(ended.Call) + } + return + } + var wire clientToolCallWire + if json.Unmarshal(frame.Payload, &wire) != nil || wire.Call == "" { + return + } + ctx, incoming, handler := c.beginClientToolCall(wire) + if incoming == nil { + return + } + guard.Go("remote/client-tool-handler", func() { + defer incoming.cancel(context.Canceled) + defer c.finishClientToolCall(wire.Call, incoming) + call := session.ClientToolCall{Set: wire.Set, Name: wire.Name, TurnCall: wire.TurnCall, + SourceMessage: wire.SourceMessage, Args: wire.Args, DeadlineMS: wire.DeadlineMS} + answer := session.ClientToolUnavailable(call.Name) + if handler != nil { + var err error + answer, err = handler(ctx, call) + if err != nil { + answer = session.ClientToolAnswer{Text: err.Error(), IsError: true} + } + } + if c.clientToolCallActive(wire.Call, incoming) && ctx.Err() == nil { + _, _ = c.call(ctx, MethodClientToolAnswer, + clientToolAnswerWire{Call: wire.Call, Text: answer.Text, IsError: answer.IsError}) + } + }) +} + +func (c *Client) replayClientToolSets() ([]session.ClientToolSet, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.clientTools.attached { + return nil, false + } + if clientToolConversationChanged(c.clientTools.welcome, c.welcome) { + c.forgetClientToolsLocked() + return nil, false + } + return c.clientTools.sets, c.welcome.ClientTools +} + +func (c *Client) resendClientTools() { + // The reader must resume before a synchronous snapshot can receive its reply. + guard.Go("remote/client-tools-reattach", func() { + c.clientTools.setMu.Lock() + defer c.clientTools.setMu.Unlock() + sets, attached := c.replayClientToolSets() + if !attached { + return + } + if _, err := c.call(context.Background(), MethodClientToolsSet, + clientToolSetsWire{Owner: c.clientTools.owner, Sets: sets}); err != nil { + c.note("client tools could not be reattached: " + err.Error()) + } + }) +} diff --git a/internal/remote/clienttools_test.go b/internal/remote/clienttools_test.go new file mode 100644 index 0000000000..7473647d4e --- /dev/null +++ b/internal/remote/clienttools_test.go @@ -0,0 +1,936 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func clientToolTestSet(id, name string) session.ClientToolSet { + return session.ClientToolSet{ID: id, Instructions: "Use the attached tool.", DeadlineMS: 1000, + Tools: []session.ClientTool{{Name: name, Description: "An app action", + InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionEdit}}} +} + +func clientToolTestPair(t *testing.T, held *Session) *Loop { + t.Helper() + loop, err := LoopbackAttach(Hello{}, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + return loop +} + +// C1, C6, C9: capability discovery precedes attachment, and refusals are atomic. +func TestClientToolsRemoteCapabilityAndSnapshot(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{model: "test/model"}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + if !loop.Client.Agent().SupportsClientTools() { + t.Fatal("holder did not advertise forwarding") + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("an unattached conversation has client tools") + } + set := clientToolTestSet("notes", "app_note") + if names, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil || len(names) != 1 || names[0] != "app_note" { + t.Fatalf("attach: %v %v", names, err) + } + version, sets := hub.ClientToolSets() + if len(sets) != 1 || sets[0].Instructions != set.Instructions || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("snapshot: %+v", sets) + } + set.Tools[0].InputSchema = json.RawMessage(`{"type":"string"}`) + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err == nil { + t.Fatal("holder accepted an invalid schema") + } + if next, _ := hub.ClientToolSets(); next != version { + t.Fatal("a refusal changed the snapshot") + } + oldHeld := NewSession(engineOn(&fakeAgent{}), true) + t.Cleanup(func() { _ = oldHeld.Close() }) + older := clientToolTestPair(t, oldHeld) + before := older.CallsMade() + if _, err := older.Client.Agent().SetClientTools(context.Background(), nil); !errors.Is(err, ErrClientToolsUnsupported) { + t.Fatalf("old holder: %v", err) + } + if older.CallsMade() != before { + t.Fatal("sent a new method to an old holder") + } +} + +// C3: all three departures remove sets and settle calls before the ordinary wait. +func TestClientToolsRemoteDisconnectDetachAndClose(t *testing.T) { + for _, road := range []string{"pipe", "detach", "close"} { + t.Run(road, func(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + entered, ended := make(chan struct{}), make(chan struct{}) + loop.Client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + close(entered) + <-ctx.Done() + close(ended) + return session.ClientToolAnswer{}, nil + }) + answer := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + switch road { + case "pipe": + _ = loop.Client.Close() + case "detach": + if err := loop.Client.Agent().Detach(); err != nil { + t.Fatal(err) + } + case "close": + if err := loop.Client.Agent().Close(); err != nil { + t.Fatal(err) + } + } + got := clientToolTestAnswer(t, answer) + if !got.IsError || got.Text != "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." { + t.Fatalf("departure answer: %+v", got) + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("departed connection still has sets") + } + _ = loop.Client.Close() + clientToolTestSignal(t, ended) + }) + } +} + +func clientToolTestSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(3 * time.Second): + t.Fatal("client tool signal did not arrive") + } +} + +type clientToolRawSurface struct { + conn net.Conn + owner string + frames chan Frame + errors chan error + next uint64 + callFrames atomic.Int32 +} + +func clientToolTestRaw(t *testing.T, held *Session, watch bool) *clientToolRawSurface { + t.Helper() + return clientToolTestRawOutput(t, held, watch, func(out io.Writer) io.Writer { return out }) +} + +func clientToolTestRawOutput(t *testing.T, held *Session, watch bool, wrap func(io.Writer) io.Writer) *clientToolRawSurface { + t.Helper() + surface, engine := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer engine.Close() + _ = ServeAttach(engine, wrap(engine), AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + t.Cleanup(func() { + _ = surface.Close() + <-done + }) + raw := &clientToolRawSurface{conn: surface, owner: newClientID(), frames: make(chan Frame, 64), errors: make(chan error, 1)} + go func() { + reader := json.NewDecoder(surface) + for { + var frame Frame + if err := reader.Decode(&frame); err != nil { + raw.errors <- err + return + } + if frame.Kind == clientToolCallKind { + raw.callFrames.Add(1) + } + raw.frames <- frame + } + }() + if err := json.NewEncoder(surface).Encode(Frame{Kind: "hello", Payload: mustJSON(Hello{Version: Version, Watch: watch})}); err != nil { + t.Fatal(err) + } + raw.frame(t, "welcome") + return raw +} + +func (r *clientToolRawSurface) frame(t *testing.T, kind string) Frame { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + for { + select { + case frame := <-r.frames: + if frame.Kind == kind { + return frame + } + case err := <-r.errors: + t.Fatal(err) + case <-timer.C: + t.Fatalf("no %s frame", kind) + } + } +} + +func (r *clientToolRawSurface) call(t *testing.T, method string, args any) Frame { + t.Helper() + if method == MethodClientToolsSet { + args = clientToolSetsWire{Owner: r.owner, Sets: args.([]session.ClientToolSet)} + } + r.next++ + if err := json.NewEncoder(r.conn).Encode(Frame{Kind: "call", ID: r.next, Method: method, Payload: mustJSON(args)}); err != nil { + t.Fatal(err) + } + return r.frame(t, "result") +} + +// C3, C5: cancellation reaches the owner, and foreign or late answers change nothing. +func TestClientToolsRemoteWaitEndAndAnswerOwnership(t *testing.T) { + for _, timedOut := range []bool{false, true} { + t.Run(fmt.Sprintf("timeout=%t", timedOut), func(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + owner := clientToolTestRaw(t, held, false) + foreign := clientToolTestPair(t, held) + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); reply.Error != "" { + t.Fatal(reply.Error) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := clientToolTestCall(hub, ctx, "app_note") + var call clientToolCallWire + if err := json.Unmarshal(owner.frame(t, clientToolCallKind).Payload, &call); err != nil { + t.Fatal(err) + } + payload, err := foreign.Client.call(ctx, MethodClientToolAnswer, clientToolAnswerWire{Call: call.Call, Text: "wrong connection"}) + if err != nil || string(payload) != `{"accepted":false}` { + t.Fatalf("foreign answer: %s %v", payload, err) + } + select { + case got := <-answer: + t.Fatalf("foreign answer settled the call: %+v", got) + default: + } + if !timedOut { + cancel() + } + got := clientToolTestAnswer(t, answer) + want := "the turn ended before the app answered app_note; the outcome is unknown, so check before trying again." + if timedOut { + want = "client_timeout: the app that provides app_note did not answer within 1s; the outcome is unknown, so check before trying again." + } + if !got.IsError || got.Text != want { + t.Fatalf("wait answer: %+v", got) + } + var ended clientToolEndedWire + if err := json.Unmarshal(owner.frame(t, clientToolEndedKind).Payload, &ended); err != nil || ended.Call != call.Call { + t.Fatalf("call-ended frame: %+v %v", ended, err) + } + late := owner.call(t, MethodClientToolAnswer, clientToolAnswerWire{Call: call.Call, Text: "too late"}) + if late.Error != "" || string(late.Payload) != `{"accepted":false}` { + t.Fatalf("late answer: %+v", late) + } + }) + } +} + +// C1, C5: holder validation applies to raw surfaces and counts all connections. +func TestClientToolsRemoteHolderValidationAndAggregateLimits(t *testing.T) { + hub := &ClientToolHub{} + hub.ClientToolBuiltins([]string{"fixture_builtin"}) + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + a, b := clientToolTestPair(t, held), clientToolTestPair(t, held) + ctx := context.Background() + for _, change := range []func(*session.ClientToolSet){ + func(s *session.ClientToolSet) { s.ID = "Bad id" }, + func(s *session.ClientToolSet) { + s.Instructions = strings.Repeat("x", session.ClientToolInstructionsMax+1) + }, + func(s *session.ClientToolSet) { s.DeadlineMS = 999 }, + func(s *session.ClientToolSet) { s.DeadlineMS = 120001 }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "Bad-name" }, + func(s *session.ClientToolSet) { s.Tools[0].Description = "" }, + func(s *session.ClientToolSet) { s.Tools[0].InputSchema = json.RawMessage(`null`) }, + func(s *session.ClientToolSet) { s.Tools[0].InputSchema = json.RawMessage(`{"type":"array"}`) }, + func(s *session.ClientToolSet) { s.Tools[0].Category = session.ActionWork }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "fixture_builtin" }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "bash" }, + } { + set := clientToolTestSet("notes", "app_note") + change(&set) + if _, err := a.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{set}); err == nil { + t.Fatalf("holder accepted invalid declaration: %+v", set) + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("refusal partially attached tools") + } + } + sets := []session.ClientToolSet{} + for i := range 4 { + sets = append(sets, clientToolTestSet(fmt.Sprintf("set%d", i), fmt.Sprintf("app_%d", i))) + } + if _, err := a.Client.Agent().SetClientTools(ctx, sets); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("extra", "app_extra")}); err == nil { + t.Fatal("aggregate fifth set accepted") + } + for i := range 2 { + sets[i].Tools = nil + for j := range 32 { + tool := clientToolTestSet("unused", fmt.Sprintf("app_%d_%d", i, j)).Tools[0] + sets[i].Tools = append(sets[i].Tools, tool) + } + } + if _, err := a.Client.Agent().SetClientTools(ctx, sets[:2]); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("extra", "app_extra")}); err == nil { + t.Fatal("aggregate 65th tool accepted") + } + watcher := clientToolTestRaw(t, held, true) + if reply := watcher.call(t, MethodClientToolsSet, []session.ClientToolSet{}); reply.Error == "" { + t.Fatal("a watcher changed the belt") + } +} + +// C4: roaming republishes its last acknowledged snapshot on the replacement link. +func TestClientToolsRemoteRedialRestoresSnapshot(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + var mu sync.Mutex + var first io.ReadWriteCloser + dialled := 0 + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + surface, pipe := Pipe() + func() { + mu.Lock() + defer mu.Unlock() + dialled++ + if first == nil { + first = surface + } + }() + go func() { + defer pipe.Close() + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + version, _ := hub.ClientToolSets() + initial := func() io.ReadWriteCloser { + mu.Lock() + defer mu.Unlock() + return first + }() + _ = initial.Close() + waitFor(t, "client tool snapshot after redial", func() bool { + mu.Lock() + defer mu.Unlock() + redialled := dialled > 1 + next, sets := hub.ClientToolSets() + return redialled && next > version && len(sets) == 1 && sets[0].Tools[0].Name == "app_note" + }) +} + +// C3, C4, C5: a replay moves ownership before the half-open old link detaches. +func TestClientToolsRemoteRedialMovesAttachedOwner(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub, SessionFile: "/conversation.jsonl"}, true) + t.Cleanup(func() { _ = held.Close() }) + var first net.Conn + var dialled atomic.Int32 + served := make(chan struct{}, 8) + oldDetached := make(chan struct{}) + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + n := dialled.Add(1) + surface, pipe := net.Pipe() + if n == 1 { + first = surface + } + go func() { + defer func() { served <- struct{}{} }() + defer pipe.Close() + if n == 1 { + defer close(oldDetached) + } + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = first.Close() + for range int(dialled.Load()) { + clientToolTestSignal(t, served) + } + }) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + version, _ := hub.ClientToolSets() + entered := make(chan struct{}, 1) + var calls atomic.Int32 + client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + if calls.Add(1) == 1 { + entered <- struct{}{} + <-ctx.Done() + return session.ClientToolAnswer{}, ctx.Err() + } + return session.ClientToolAnswer{Text: "replacement connection"}, nil + }) + pending := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + if err := first.SetReadDeadline(time.Now()); err != nil { + t.Fatal(err) + } + waitFor(t, "reader leaving the old connection", func() bool { + client.mu.Lock() + defer client.mu.Unlock() + return client.reconnecting + }) + if err := first.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + // Keep the old server attached and drain its room notices independently of + // the roaming reader, so its ordinary driver broadcast can finish as usual. + drained := make(chan struct{}) + go func() { + defer close(drained) + _, _ = io.Copy(io.Discard, first) + }() + waitFor(t, "replacement replay while old owner is attached", func() bool { + held.mu.Lock() + defer held.mu.Unlock() + next, sets := hub.ClientToolSets() + return dialled.Load() == 2 && len(held.surfaces) == 2 && next > version && len(sets) == 1 + }) + got := clientToolTestAnswer(t, pending) + if !got.IsError || got.Text != "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." { + t.Fatalf("old owner's pending call: %+v", got) + } + _ = first.Close() + clientToolTestSignal(t, drained) + clientToolTestSignal(t, oldDetached) + if _, sets := hub.ClientToolSets(); len(sets) != 1 { + t.Fatal("old detach removed the replacement's tools") + } + got = clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_note")) + if got.IsError || got.Text != "replacement connection" { + t.Fatalf("call after old detach: %+v", got) + } +} + +// C4: a changed welcome forgets the snapshot instead of attaching it elsewhere. +func TestClientToolsRemoteRedialConversationIdentity(t *testing.T) { + for _, files := range [][2]string{{"/a.jsonl", "/b.jsonl"}, {"", ""}, {"/a.jsonl", "/a.jsonl"}} { + t.Run(fmt.Sprintf("%s-to-%s", files[0], files[1]), func(t *testing.T) { + aHub, bHub := &ClientToolHub{}, &ClientToolHub{} + a := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: aHub, SessionFile: files[0]}, true) + b := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: bHub, SessionFile: files[1]}, true) + t.Cleanup(func() { + _ = a.Close() + _ = b.Close() + }) + var first io.ReadWriteCloser + var dialled atomic.Int32 + served := make(chan struct{}, 8) + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + n := dialled.Add(1) + surface, pipe := Pipe() + held := b + if n == 1 { + first, held = surface, a + } + go func() { + defer func() { served <- struct{}{} }() + defer pipe.Close() + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = first.Close() + for range int(dialled.Load()) { + clientToolTestSignal(t, served) + } + }) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + callsBefore := client.CallsMade() + entered, canceled := make(chan struct{}), make(chan struct{}) + client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + close(entered) + <-ctx.Done() + close(canceled) + return session.ClientToolAnswer{}, ctx.Err() + }) + pending := clientToolTestCall(aHub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + _ = first.Close() + clientToolTestSignal(t, canceled) + clientToolTestAnswer(t, pending) + wantReplay := files[0] != "" && files[0] == files[1] + waitFor(t, "client tool replay decision after changed welcome", func() bool { + client.mu.Lock() + defer client.mu.Unlock() + if client.reconnecting || client.welcome.SessionInstance != b.instance { + return false + } + _, sets := bHub.ClientToolSets() + if wantReplay { + return len(sets) == 1 + } + return !client.clientTools.attached && len(client.clientTools.sets) == 0 && len(client.clientTools.incoming) == 0 + }) + if !wantReplay { + if client.CallsMade() != callsBefore { + t.Fatal("sent a tool snapshot to another conversation") + } + if _, sets := bHub.ClientToolSets(); len(sets) != 0 { + t.Fatal("old tools reached another conversation") + } + got := clientToolTestAnswer(t, clientToolTestCall(bHub, context.Background(), "app_note")) + if !got.IsError || !strings.HasPrefix(got.Text, "client_unavailable:") { + t.Fatalf("another conversation used the old tools: %+v", got) + } + } + }) + } +} + +// C1, C2, C9: the real holder refreshes its belt and instructions at model steps. +func TestClientToolsRemoteModelBoundary(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + type request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + var mu sync.Mutex + var requests []request + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var got request + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + attached := false + for _, tool := range got.Tools { + attached = attached || tool.Function.Name == "app_note" + } + call := attached && len(got.Messages) > 0 && got.Messages[len(got.Messages)-1].Role == "user" + if len(got.Tools) > 0 { + func() { + mu.Lock() + defer mu.Unlock() + requests = append(requests, got) + }() + } + w.Header().Set("Content-Type", "text/event-stream") + if call { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-boundary-1","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"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(provider.Close) + hub := &ClientToolHub{} + agent, err := session.New(session.Config{Workspace: t.TempDir(), SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), + Model: "test/model", APIKey: "fixture", BaseURL: provider.URL, OneModel: true, + DeterministicNames: true, PromptProfile: "full", ClientTools: hub}) + if err != nil { + t.Fatal(err) + } + held := NewSession(&Engine{Agent: agent, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + loop.Client.Agent().SetClientToolHandler(func(_ context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if call.SourceMessage == "" || call.TurnCall != "provider-boundary-1" { + t.Errorf("model call identity: %+v", call) + } + return session.ClientToolAnswer{Text: `{"saved":true}`}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + turn := func() { + t.Helper() + events, err := loop.Client.Agent().Submit(ctx, "inspect the available tools") + if err != nil { + t.Fatal(err) + } + for { + select { + case event, open := <-events: + if !open { + return + } + if event.Kind == session.EventError { + t.Errorf("model turn: %v", event.Err) + } + case <-ctx.Done(): + t.Fatal("holder model turn did not finish") + } + } + } + turn() + if _, err := loop.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + turn() + if _, err := loop.Client.Agent().SetClientTools(ctx, nil); err != nil { + t.Fatal(err) + } + turn() + mu.Lock() + defer mu.Unlock() + if len(requests) != 4 { + t.Fatalf("provider requests: %d", len(requests)) + } + before, attached, after := requests[0], requests[1], requests[3] + if len(before.Tools) != len(after.Tools) || len(attached.Tools) != len(before.Tools)+1 { + t.Fatalf("belt counts: %d, %d, %d", len(before.Tools), len(attached.Tools), len(after.Tools)) + } + for i := range before.Tools { + if before.Tools[i].Function.Name != after.Tools[i].Function.Name { + t.Fatal("a conversation without sets changed its belt") + } + } + if strings.Contains(string(before.Messages[0].Content), "Tools from the app:") || strings.Contains(string(after.Messages[0].Content), "Tools from the app:") { + t.Fatal("an empty hub added instructions") + } + if !strings.Contains(string(attached.Messages[0].Content), "Tools from the app: notes") || !strings.Contains(string(attached.Messages[0].Content), "Use the attached tool.") { + t.Fatal("holder did not refresh its instructions") + } +} + +func clientToolTestAnswer(t *testing.T, answer <-chan session.ClientToolAnswer) session.ClientToolAnswer { + t.Helper() + select { + case got := <-answer: + return got + case <-time.After(3 * time.Second): + t.Fatal("client tool wait did not settle") + return session.ClientToolAnswer{} + } +} + +func clientToolTestCall(hub *ClientToolHub, ctx context.Context, name string) <-chan session.ClientToolAnswer { + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := hub.CallClientTool(ctx, session.ClientToolCall{Set: "notes", Name: name, + TurnCall: "provider-1", SourceMessage: "message-1", Args: json.RawMessage(`{"id":3}`)}) + answer <- got + }() + return answer +} + +// C2, C5: the owning connection alone receives and answers its calls. +func TestClientToolsRemoteOwnersAndAnswers(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + a, b := clientToolTestPair(t, held), clientToolTestPair(t, held) + ctx := context.Background() + if _, err := a.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("other", "app_note")}); err == nil || !strings.Contains(err.Error(), "app_note") { + t.Fatalf("collision: %v", err) + } + called := make(chan session.ClientToolCall, 1) + foreign := make(chan bool, 1) + b.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + foreign <- true + return session.ClientToolAnswer{}, nil + }) + a.Client.Agent().SetClientToolHandler(func(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + called <- call + return session.ClientToolAnswer{Text: `{"saved":true}`}, nil + }) + answer := clientToolTestAnswer(t, clientToolTestCall(hub, ctx, "app_note")) + if answer.IsError || answer.Text != `{"saved":true}` { + t.Fatalf("model answer: %+v", answer) + } + call := <-called + if call.SourceMessage != "message-1" || call.TurnCall != "provider-1" || string(call.Args) != `{"id":3}` { + t.Fatalf("call identity: %+v", call) + } + select { + case <-foreign: + t.Fatal("a call reached a different connection") + default: + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_other")}); err != nil { + t.Fatal(err) + } + a.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + foreign <- true + return session.ClientToolAnswer{}, nil + }) + b.Client.Agent().SetClientToolHandler(func(_ context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "other connection"}, nil + }) + got := clientToolTestAnswer(t, clientToolTestCall(hub, ctx, "app_other")) + if got.Text != "other connection" || got.IsError { + t.Fatalf("second owner answer: %+v", got) + } + select { + case <-foreign: + t.Fatal("the second owner's call reached the first") + default: + } +} + +type clientToolBlockedAgent struct { + *fakeAgent + entered chan struct{} + release chan struct{} +} + +func (a *clientToolBlockedAgent) SetModel(model string) { + close(a.entered) + <-a.release + a.fakeAgent.SetModel(model) +} + +// C2: tool answers bypass an ordered call that is still holding its lane. +func TestClientToolsRemoteAnswerBypassesOrderedLane(t *testing.T) { + hub := &ClientToolHub{} + agent := &clientToolBlockedAgent{fakeAgent: &fakeAgent{}, entered: make(chan struct{}), release: make(chan struct{})} + defer close(agent.release) + held := NewSession(&Engine{Agent: agent, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + loop.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "answered while the lane was held"}, nil + }) + go loop.Client.Agent().SetModel("another/model") + clientToolTestSignal(t, agent.entered) + got := clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_note")) + if got.IsError || got.Text != "answered while the lane was held" { + t.Fatalf("answer queued behind ordered work: %+v", got) + } +} + +type clientToolStalledOutput struct { + io.Writer + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (w *clientToolStalledOutput) Write(data []byte) (int, error) { + var frame Frame + if json.Unmarshal(data, &frame) == nil && frame.Kind == clientToolCallKind { + w.once.Do(func() { + close(w.entered) + <-w.release + }) + } + return w.Writer.Write(data) +} + +// C3, C5: a stuck owner blocks one sender while queued calls expire and peers work. +func TestClientToolsRemoteBlockedSenderDropsExpiredCalls(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + stalled := &clientToolStalledOutput{entered: make(chan struct{}), release: make(chan struct{})} + var release sync.Once + owner := clientToolTestRawOutput(t, held, false, func(out io.Writer) io.Writer { + stalled.Writer = out + return stalled + }) + t.Cleanup(func() { release.Do(func() { close(stalled.release) }) }) + set := clientToolTestSet("notes", "app_note") + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{set}); reply.Error != "" { + t.Fatal(reply.Error) + } + peer := clientToolTestPair(t, held) + if _, err := peer.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_other")}); err != nil { + t.Fatal(err) + } + peer.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "other owner still works"}, nil + }) + first := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, stalled.entered) + var queued []<-chan session.ClientToolAnswer + for range 16 { + queued = append(queued, clientToolTestCall(hub, context.Background(), "app_note")) + } + if got := clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_other")); got.IsError || got.Text != "other owner still works" { + t.Fatalf("blocked one owner stalled another: %+v", got) + } + for _, answer := range append(queued, first) { + got := clientToolTestAnswer(t, answer) + if !got.IsError || !strings.HasPrefix(got.Text, "client_timeout:") { + t.Fatalf("blocked send did not respect its wait: %+v", got) + } + } + func() { + hub.mu.Lock() + defer hub.mu.Unlock() + if len(hub.pending) != 0 { + t.Fatal("expired queued calls retained their arguments") + } + }() + release.Do(func() { close(stalled.release) }) + owner.frame(t, clientToolCallKind) + owner.frame(t, clientToolEndedKind) + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{set}); reply.Error != "" { + t.Fatal(reply.Error) + } + if got := owner.callFrames.Load(); got != 1 { + t.Fatalf("sent %d calls after a blocked writer resumed; queued expired calls must be skipped", got) + } +} + +type clientToolStalledLink struct { + io.ReadWriteCloser + writes int + entered chan struct{} + release chan struct{} +} + +func (p *clientToolStalledLink) Write(data []byte) (int, error) { + p.writes++ + if p.writes == 2 { + close(p.entered) + <-p.release + return 0, io.ErrClosedPipe + } + return p.ReadWriteCloser.Write(data) +} + +// C1: tool publication shares ordinary calls' writer ownership and write failures. +func TestClientToolsRemoteAttachmentUsesCallPath(t *testing.T) { + for _, method := range []string{MethodModel, MethodClientToolsSet} { + t.Run(method, func(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + surface, engine := Pipe() + served := make(chan struct{}) + go func() { + defer close(served) + defer engine.Close() + _ = ServeAttach(engine, engine, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + link := &clientToolStalledLink{ReadWriteCloser: surface, entered: make(chan struct{}), release: make(chan struct{})} + client, err := Dial(link, "engine", Hello{}) + if err != nil { + t.Fatal(err) + } + var release sync.Once + t.Cleanup(func() { + release.Do(func() { close(link.release) }) + _ = client.Close() + clientToolTestSignal(t, served) + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + failed := make(chan error, 1) + go func() { + if method == MethodClientToolsSet { + _, err := client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}) + failed <- err + return + } + _, err := client.call(ctx, method, nil) + failed <- err + }() + clientToolTestSignal(t, link.entered) + cancel() + select { + case err := <-failed: + t.Fatalf("call abandoned its synchronous writer: %v", err) + default: + } + release.Do(func() { close(link.release) }) + select { + case err := <-failed: + if err == nil || err.Error() != "the connection to engine is gone — run the same command to pick the conversation back up" { + t.Fatalf("ordinary write failure: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("call retained a writer after its transport write ended") + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("a failed write changed the holder") + } + }) + } +} diff --git a/internal/remote/redial.go b/internal/remote/redial.go index 06721c11f9..07578f7d60 100644 --- a/internal/remote/redial.go +++ b/internal/remote/redial.go @@ -166,6 +166,7 @@ func plural(n int, word string) string { // stream state has been reconciled with what the engine says; false means this // client is over and [Client.bury] has already said why. func (c *Client) lost(cause error) bool { + c.endClientToolCalls() c.closeObservers() c.mu.Lock() roam, closing, dead := c.roam, c.closing, c.dead @@ -210,6 +211,7 @@ func (c *Client) lost(cause error) bool { } c.reconcile(left, welcome) c.retakeLanes(left, welcome) + c.resendClientTools() return true } diff --git a/internal/remote/server.go b/internal/remote/server.go index 9e41d9d1b4..6cecbc1980 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -136,6 +136,8 @@ type WrappedAgent interface { type Engine struct { // EntryNotice reads home-owned recovery news when an interactive surface opens. EntryNotice func(string) string + // ClientTools is the conversation's connection-owned forwarding door. + ClientTools *ClientToolHub // SyncService is the engine's address for chat outage notices. SyncService string // DeltaWindow lets each of this conversation's streams fold streamed text @@ -1216,6 +1218,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // gets around to reporting the end, which may be after the window that replaced // it has already been welcomed (held.go states the whole rule). func (sess *Session) detach(s *server, deliberate bool) { + s.dropClientTools() // THE RAIL'S SUBSCRIPTION GOES WITH THE WINDOW. It is left before anything // else because leaving it is what ends the goroutine pumping frames at a // pipe that is closing (tasklane.go). @@ -1377,6 +1380,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { } _, workAdmission := sess.agent.(interface{ CheckWork() error }) return Welcome{ + ClientTools: sess.engine.ClientTools != nil, WorkAdmission: workAdmission, UserMessages: users, MessageMarks: messageMarksKnown(sess.agent), @@ -1845,6 +1849,7 @@ func (sess *Session) swap(asked *server, build func() (WrappedAgent, string, boo // ── one connection ────────────────────────────────────────────────────────── type server struct { + clientToolsGone atomic.Bool // write guards the writer. ONE FRAME IS ONE LINE IS ONE WRITE: a torn frame // is not a slow surface, it is a surface that can never parse this stream // again, so every writer on this process goes through here. @@ -1989,6 +1994,7 @@ func (s *server) serve(in io.Reader) (err error) { s.ordered = newOrderedLane() go s.ordered.run(s.dispatch) defer func() { + s.dropClientTools() s.ordered.close() s.ordered.wait() }() @@ -2373,6 +2379,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return out, err } switch call.Method { + case MethodClientToolsSet, MethodClientToolAnswer: + return s.invokeClientTools(call) case MethodProcessList, MethodProcessRerun, MethodProcessPage, MethodProcessRecord, MethodProcessActions, MethodProcessRerunAt, MethodProcessStopAt, MethodProcessStopAll: return serveAppProcesses(agent, call) @@ -3116,6 +3124,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, err case MethodClose: + s.dropClientTools() // The surface said goodbye politely, and it is saying it about the // CONVERSATION rather than about this window — so [server.leave] closes // the session behind it whoever else is attached. The journal is flushed @@ -3881,9 +3890,14 @@ func (s *server) sendLocked(frame Frame) error { } if _, err := s.out.Write(line); err != nil { s.dead = true + s.dropClientTools() return err } - return flushFrame(s.out) + err = flushFrame(s.out) + if err != nil { + s.dropClientTools() + } + return err } func (s *server) hungUp() bool { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 41daacb68b..2c97706f6e 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -393,6 +393,9 @@ import ( // are new. The number moves because a version-21 engine would answer the // place's move as "no such method" — a key that did nothing, and a person told // so in words that were not true. +// +// ClientTools.Set and ClientTool.Answer ride version 22 behind Welcome.ClientTools. +// Like the other welcome capabilities, absence preserves the existing wire. const Version = 22 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods @@ -417,7 +420,8 @@ const Version = 22 type Frame struct { // Kind says what this frame is: "hello", "welcome", "call", "result", // "event", "closed", "facts", "task", "design", "question", "phase", "lane", - // "turn", "driver", "moved", "fatal", "sideAskDelta". + // "turn", "driver", "moved", "fatal", "sideAskDelta", "clientToolCall", + // "clientToolEnded". The two client-tool kinds answer nothing. // // "facts", "task", "design", "question", "phase" and "lane" are the KINDS // THAT ANSWER NOTHING. The third and fourth are version 11's harness lane @@ -1100,6 +1104,8 @@ type StreamCursor struct { // Welcome is the server's answer ("welcome"): the facts a surface needs before // its first frame, which are the same facts newApp reads off a local agent. type Welcome struct { + // ClientTools says this holder forwards connection-owned client tools. + ClientTools bool `json:"clientTools,omitempty"` // WorkAdmission says this engine exposes the session's ownership gate. WorkAdmission bool `json:"workAdmission,omitempty"` // UserMessages is counted from the engine's replay, so an attaching app diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go index ee710beac6..134512985a 100644 --- a/internal/session/client_tools.go +++ b/internal/session/client_tools.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "regexp" "strings" "time" @@ -42,10 +43,50 @@ type ClientTool struct { } type ClientToolSet struct { - ID string - Instructions string - DeadlineMS int - Tools []ClientTool + ID string `json:"id"` + Instructions string `json:"instructions"` + DeadlineMS int `json:"deadline_ms"` + Tools []ClientTool `json:"tools"` +} + +var clientToolSetPattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) +var clientToolNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// ValidateClientToolSet checks declarations at a conversation's transport door. +func ValidateClientToolSet(set ClientToolSet) error { + if !clientToolSetPattern.MatchString(set.ID) { + return fmt.Errorf("a valid set id is required") + } + if len(set.Tools) > ClientToolMaxPerSet || len(set.Instructions) > ClientToolInstructionsMax { + return fmt.Errorf("a set allows at most %d tools and %d instruction bytes", ClientToolMaxPerSet, ClientToolInstructionsMax) + } + if set.DeadlineMS != 0 && (set.DeadlineMS < ClientToolMinDeadlineMS || set.DeadlineMS > ClientToolMaxDeadlineMS) { + return fmt.Errorf("deadline_ms must be 0 or %d–%d", ClientToolMinDeadlineMS, ClientToolMaxDeadlineMS) + } + names := make(map[string]bool, len(set.Tools)) + for _, tool := range set.Tools { + if !clientToolNamePattern.MatchString(tool.Name) || names[tool.Name] { + return fmt.Errorf("invalid or duplicate tool name %q", tool.Name) + } + names[tool.Name] = true + if len(tool.Description) == 0 || len(tool.Description) > ClientToolDescriptionMax { + return fmt.Errorf("%s description must be 1–%d bytes", tool.Name, ClientToolDescriptionMax) + } + var object map[string]json.RawMessage + if len(tool.InputSchema) > ClientToolSchemaMax || json.Unmarshal(tool.InputSchema, &object) != nil || object == nil { + return fmt.Errorf("%s input_schema must be an object of at most %d bytes", tool.Name, ClientToolSchemaMax) + } + var kind string + if json.Unmarshal(object["type"], &kind) != nil || kind != "object" { + return fmt.Errorf("%s input_schema type must be object", tool.Name) + } + switch tool.Category { + case ActionRead, ActionEdit, ActionCreate: + default: + return fmt.Errorf("%s category must be read, edit or create", tool.Name) + } + } + return nil } type ClientToolCall struct { From 2a583ea7c1cb23e66122d48b03c23e2339e45e84 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 11:57:08 -0500 Subject: [PATCH 4/8] A conversation can be told only about its own work (agents.set_scope) Carries #2462 (e985a4440) from workstreams/main. Co-Authored-By: Claude Mythos 5.2 --- cmd/codeaf/appscope.go | 44 ++ cmd/codeaf/appscope_test.go | 154 ++++++ docs/changes/unreleased/2462-agent-scope.md | 18 + docs/design/app-v3/PROTOCOL.md | 24 + internal/appwire/agent_scope.go | 43 ++ internal/appwire/agent_scope_test.go | 92 ++++ internal/appwire/server.go | 1 + internal/manual/chat/tasks.md | 29 +- internal/session/place.go | 6 + internal/session/scope.go | 61 +++ internal/session/scope_test.go | 502 ++++++++++++++++++++ internal/session/taskdelta.go | 12 + internal/session/tools_tasks.go | 27 +- 13 files changed, 1002 insertions(+), 11 deletions(-) create mode 100644 cmd/codeaf/appscope.go create mode 100644 cmd/codeaf/appscope_test.go create mode 100644 docs/changes/unreleased/2462-agent-scope.md create mode 100644 internal/appwire/agent_scope.go create mode 100644 internal/appwire/agent_scope_test.go create mode 100644 internal/session/scope.go create mode 100644 internal/session/scope_test.go diff --git a/cmd/codeaf/appscope.go b/cmd/codeaf/appscope.go new file mode 100644 index 0000000000..8818e1f71c --- /dev/null +++ b/cmd/codeaf/appscope.go @@ -0,0 +1,44 @@ +package main + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// SetAgentScope writes the conversation's stored scope without opening it. +// A held conversation reads the word beside its next task-index reading. +func (b *appBackend) SetAgentScope(id, scope string) (appwire.AgentScope, error) { + if scope != session.WorkScopeProject && scope != session.WorkScopeConversation { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeBadParams, "scope is project or conversation") + } + var a *appAgent + b.locked(func() { a = b.agents[id] }) + if a == nil { + var err error + a, err = b.seenTarget(id) + if err != nil { + return appwire.AgentScope{}, err + } + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.lentFence(a, "scope"); err != nil { + return appwire.AgentScope{}, err + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if strings.TrimSpace(transcript) == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + dir := session.DirOf(transcript) + meta, err := session.LoadMeta(dir) + if err != nil || meta.ID == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + if _, err := session.SetConversationScope(dir, scope); err != nil { + return appwire.AgentScope{}, appEngineError(err) + } + return appwire.AgentScope{ID: id, Scope: session.ConversationScopeAt(dir)}, nil +} diff --git a/cmd/codeaf/appscope_test.go b/cmd/codeaf/appscope_test.go new file mode 100644 index 0000000000..2dd906ffc8 --- /dev/null +++ b/cmd/codeaf/appscope_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// L-FIX9-cross A7 +func TestAppScopeWritesAClosedConversationWithoutOpeningIt(t *testing.T) { + d := newUnreadDisk(t) + if err := d.backend.state.write(func(f *appStateFile) error { + f.Closed = map[string]time.Time{d.id: time.Now()} + return nil + }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil { + t.Fatal(err) + } + w := newActionWire(t, d.backend) + if !slices.Contains(w.hello, appwire.FeatureAgentScope) { + t.Fatal("the backend does not announce agents.scope") + } + for _, word := range []string{session.WorkScopeConversation, session.WorkScopeProject} { + raw, failure := w.call("agents.set_scope", map[string]string{"id": d.id, "scope": word}) + if failure != nil { + t.Fatal(failure) + } + var result appwire.AgentScope + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + if result != (appwire.AgentScope{ID: d.id, Scope: word}) || session.ConversationScopeAt(d.dir) != word { + t.Fatalf("set %q = %+v, stored %q", word, result, session.ConversationScopeAt(d.dir)) + } + } + d.mu.Lock() + opens := d.opens + d.mu.Unlock() + if opens != 0 || d.backend.held(d.id) || !d.backend.tidied(d.id) { + t.Fatalf("scope opened or reopened the conversation: opens %d, held %v, closed %v", opens, d.backend.held(d.id), d.backend.tidied(d.id)) + } + after, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed the transcript: %q, %v", after, err) + } + d.rec.mu.Lock() + defer d.rec.mu.Unlock() + if slices.Contains(d.rec.events, appwire.EventAgentChanged) { + t.Fatalf("scope emitted agent.changed: %v", d.rec.events) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesAnUnknownAgent(t *testing.T) { + d := newUnreadDisk(t) + w := newActionWire(t, d.backend) + if _, err := w.call("agents.set_scope", map[string]string{"id": "no-such-agent", "scope": session.WorkScopeConversation}); err == nil || err.Code != appwire.CodeNotFound { + t.Fatalf("unknown agent = %v", err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesALentConversationBeforeWriting(t *testing.T) { + backend, _, agent, dir := liveUnread(t, &fakeConversation{}) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + backend.locked(func() { a.live.conn.Lent = true }) + path := filepath.Join(dir, "meta.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + _, err = backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + want := errLentStage("scope").(*appwire.Error) + if !ok || !reflect.DeepEqual(failure, want) { + t.Fatalf("lent scope change = %v, want %v", err, want) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("refused scope changed metadata: %q, %v", after, err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeLeavesAHeldConversationAndItsUseTimeAlone(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent, dir := liveUnread(t, conv) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + var before *appLive + var used time.Time + var view appwire.Agent + backend.locked(func() { before, used, view = a.live, a.live.lastUse, a.view }) + result, err := backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + if err != nil || result.Scope != session.WorkScopeConversation || session.ConversationScopeAt(dir) != session.WorkScopeConversation { + t.Fatalf("held scope = %+v, %v", result, err) + } + backend.locked(func() { + if a.live != before || !a.live.lastUse.Equal(used) || !reflect.DeepEqual(a.view, view) { + t.Errorf("scope changed the held conversation or warmed it") + } + }) + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.submits != 0 || conv.interrupted != 0 { + t.Fatalf("scope drove the conversation: submits %d, interruptions %d", conv.submits, conv.interrupted) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRequiresALocalConversationFolder(t *testing.T) { + for _, where := range []string{"no transcript", "missing folder", "missing metadata"} { + t.Run(where, func(t *testing.T) { + d := newUnreadDisk(t) + a, err := d.backend.seenTarget(d.id) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + transcript := "" + if where == "missing folder" { + dir = filepath.Join(dir, "absent") + transcript = filepath.Join(dir, "transcript.jsonl") + } else if where == "missing metadata" { + transcript = filepath.Join(dir, "transcript.jsonl") + } + d.backend.locked(func() { a.transcript = transcript }) + _, err = d.backend.SetAgentScope(d.id, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + if !ok || failure.Code != appwire.CodeConflict || failure.Message != "this conversation has no local folder on this computer" { + t.Fatalf("%s = %v", where, err) + } + if _, err := os.Stat(filepath.Join(dir, "meta.json")); !os.IsNotExist(err) { + t.Fatalf("scope created a conversation: %v", err) + } + }) + } +} diff --git a/docs/changes/unreleased/2462-agent-scope.md b/docs/changes/unreleased/2462-agent-scope.md new file mode 100644 index 0000000000..c05b340d01 --- /dev/null +++ b/docs/changes/unreleased/2462-agent-scope.md @@ -0,0 +1,18 @@ +--- +kind: added +title: A conversation can be told only about its own work (agents.set_scope) +pr: 2462 +surface: [app, engine] +invalidates: + - "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and restores the earlier behavior." +--- + +`agents.set_scope {id, scope}` stores `project` or `conversation` and answers the +stored word. Engines serving it announce `agents.scope`. The choice survives a +restart and takes effect on the next turn or tasks call without opening, +warming or interrupting the conversation. Its folder, transcript, title, model +and tasks stay as they were. A lent conversation or one without a local folder +is refused. + +The person's task list, task nodes and the write-safety checks keep their +existing behavior. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 37691baeae..b4028350e6 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -75,11 +75,35 @@ decides which combinations are accepted. | `agents.set_effort` | `{effort, id}` | `agentResult` | | `agents.set_model` | `{automatic, id, model}` | `agentResult` | | `agents.set_models` | `{fallback, id, main}` | `agentResult` | +| `agents.set_scope` | `{id, scope}` | `AgentScope` | | `agents.since` | `{after, id}` | `Since` | | `agents.steer` | `in.sayTo` | `Empty` | | `agents.stop` | `in.IDParams` | `Empty` | | `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` | +## Conversation work scope + +`agents.set_scope {id, scope}` answers `{id, scope}` with the stored word. +`project` is the default: the conversation is told about other conversations' +landed and running work in its folder, as today. `conversation` means it is told +only about its own work: its turns receive no other-conversation `` +block, and its `tasks` tool lists and addresses only its own tasks. Neither the +other-window running section nor the wider `scope: "everywhere"` section is +added. Task nodes keep their existing scope. + +The choice is stored with the conversation and survives restarts. It takes +effect from the conversation's next turn; a live tasks call also reads the +stored choice. Setting it never opens, warms or interrupts the conversation, +never moves its folder, and never changes its transcript, title, model or tasks. +The write-safety checks still warn before its own work writes over files other +conversations are writing. The person's task list is unchanged. + +Engines that serve this method announce `agents.scope` in `hello.features`. +A missing id or a scope other than `project` or `conversation` is `bad_params`; +an engine without the door answers `unknown_method`. An unknown conversation is +`not_found`; a lent conversation or one without a local folder is `conflict`. +No `agent.changed` event is emitted, because the Agent view carries no scope. + ## Client tools The optional features `agents.client_tools` and `agents.client_tools.source` diff --git a/internal/appwire/agent_scope.go b/internal/appwire/agent_scope.go new file mode 100644 index 0000000000..03c95abad6 --- /dev/null +++ b/internal/appwire/agent_scope.go @@ -0,0 +1,43 @@ +package appwire + +// FeatureAgentScope names an engine that keeps a conversation's work scope. +const FeatureAgentScope = "agents.scope" + +type AgentScope struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` +} + +type AgentScoper interface { + SetAgentScope(id, scope string) (AgentScope, error) +} + +func withAgentScopeFeature(h Hello, e Engine) Hello { + if _, ok := e.(AgentScoper); ok { + return withFeature(h, FeatureAgentScope) + } + return h +} + +func init() { + methods["agents.set_scope"] = typed(func(e Engine, p struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` + }) (*AgentScope, error) { + if p.ID == "" { + return nil, Errorf(CodeBadParams, "agents.set_scope needs an id") + } + if p.Scope != "project" && p.Scope != "conversation" { + return nil, Errorf(CodeBadParams, "scope is project or conversation") + } + scoper, ok := e.(AgentScoper) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.set_scope") + } + out, err := scoper.SetAgentScope(p.ID, p.Scope) + if err != nil { + return nil, err + } + return &out, nil + }) +} diff --git a/internal/appwire/agent_scope_test.go b/internal/appwire/agent_scope_test.go new file mode 100644 index 0000000000..d5e71b2a8d --- /dev/null +++ b/internal/appwire/agent_scope_test.go @@ -0,0 +1,92 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "slices" + "testing" +) + +type scopeEngine struct { + fakeEngine +} + +func (e *scopeEngine) SetAgentScope(id, scope string) (AgentScope, error) { + if id == "missing" { + return AgentScope{}, Errorf(CodeNotFound, "there is no conversation with id missing") + } + return AgentScope{ID: id, Scope: scope}, nil +} + +// L-FIX9-cross A7 +func TestAgentScopeIsAnnouncedAndServedOnlyWithItsDoor(t *testing.T) { + engine := &scopeEngine{} + w := startWireOver(t, engine, &engine.fakeEngine) + if !slices.Contains(features(w.next()), FeatureAgentScope) { + t.Fatal("hello lacks agents.scope") + } + for i, word := range []string{"conversation", "project"} { + request, err := json.Marshal(map[string]any{ + "id": i + 1, "method": "agents.set_scope", "params": map[string]string{"id": "a1", "scope": word}, + }) + if err != nil { + t.Fatal(err) + } + w.send(string(request)) + got := w.next() + want := map[string]any{"id": "a1", "scope": word} + if got["error"] != nil || !reflect.DeepEqual(got["result"], want) { + t.Fatalf("scope %q = %v", word, got) + } + } + plain := startWire(t, &fakeEngine{}) + if slices.Contains(features(plain.next()), FeatureAgentScope) { + t.Fatal("an engine without the door announced agents.scope") + } + plain.send(`{"id":3,"method":"agents.set_scope","params":{"id":"a1","scope":"conversation"}}`) + got := plain.next() + if failure, ok := got["error"].(map[string]any); !ok || failure["code"] != CodeUnknownMethod || failure["message"] != "this engine does not serve agents.set_scope" { + t.Fatalf("an unsupported engine = %v", got) + } +} + +// L-FIX9-cross A7 +func TestAgentScopeValidatesParametersAndCarriesEngineErrors(t *testing.T) { + for _, tc := range []struct { + params, code, message string + }{ + {`{"scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"","scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"a1"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"everywhere"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"Conversation"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"missing","scope":"conversation"}`, CodeNotFound, "there is no conversation with id missing"}, + } { + _, err := methods["agents.set_scope"].call(&scopeEngine{}, json.RawMessage(tc.params)) + failure, ok := err.(*Error) + if !ok || failure.Code != tc.code || failure.Message != tc.message { + t.Fatalf("params %s = %v, want %s: %s", tc.params, err, tc.code, tc.message) + } + } +} + +// L-FIX9-cross A7 +func TestAgentScopeSchemaNamesBothWordsAndTheResult(t *testing.T) { + doc := Schema() + method := doc["methods"].(map[string]any)["agents.set_scope"].(map[string]any) + params := method["params"].(map[string]any)["properties"].(map[string]any) + result := doc["$defs"].(map[string]any)["AgentScope"].(map[string]any) + for name, properties := range map[string]map[string]any{ + "params": params, "result": result["properties"].(map[string]any), + } { + if properties["id"].(map[string]any)["type"] != "string" { + t.Fatalf("%s lacks a string id: %v", name, properties) + } + if got := properties["scope"].(map[string]any)["enum"]; !reflect.DeepEqual(got, []string{"project", "conversation"}) { + t.Fatalf("%s scope enum = %v", name, got) + } + } + if got := method["result"].(map[string]any)["$ref"]; got != "#/$defs/AgentScope" { + t.Fatalf("result schema = %v", method["result"]) + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index a4f4f3b5fd..ae65f03b69 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -215,6 +215,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withCrewsOrganizeFeature(hello, engine) hello = withReplySuggestionFeature(hello, engine) hello = withUnreadFeature(hello, engine) + hello = withAgentScopeFeature(hello, engine) hello = withErrandFeature(hello, engine) hello = withClientToolsFeature(hello, engine) hello = withAsksDrawnFeature(hello, engine) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index fcdba1700d..67ae561312 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2862,8 +2862,9 @@ it had started. ## Does it know what my other windows are doing — will it notice work from another terminal -Yes, and it is told rather than having to go and ask. When another codeaf window on this -same directory lands a task, or has one running, codeaf puts a small block into the chat's +With the default `project` conversation scope, yes: it is told rather than having to +go and ask. When another codeaf window on this same directory lands a task, or has +one running, codeaf puts a small block into the chat's own context — never on your screen — that reads: ``` @@ -2900,10 +2901,30 @@ running in another window now: told. - A **task** is never given this block. A task's brief is its whole world. +## Conversation scope — tell this chat only about its own work + +A client can set a saved conversation's scope with `agents.set_scope {id, scope}`. +`project` is the default: the chat is told about other conversations' landed and +running work in the same folder. `conversation` means it is told only about its +own work. No `` block is added to its turns, and the model's `tasks` +tool lists only its own tasks. Another conversation's tasks cannot be read by +name or id, steered or resolved through that tool. The running-in-other-windows +section and the `scope: "everywhere"` section are absent too. + +The choice survives a restart and takes effect from the next turn; a tasks call +also reads the saved choice. Setting it does not open or interrupt the chat, +move its folder, or change its transcript, title, model or tasks. Set `project` +to restore the ordinary reading. This setting has no terminal command or UI +control. The person's `@` task list and task nodes keep their existing behavior. + +The checks that warn before this chat's own work writes over files other +conversations are writing still apply. + ## Asking the chat what else is running on this project right now -Ask it in words. It reads the other windows itself rather than guessing from the project's -record — which cannot answer, because an ordinary task writes no row there until it lands. +With `project` conversation scope, ask it in words. It reads the other windows itself +rather than guessing from the project's record — which cannot answer, because an +ordinary task writes no row there until it lands. What it gets back looks like this: ``` diff --git a/internal/session/place.go b/internal/session/place.go index d7c6102f64..2e1187b628 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -296,6 +296,9 @@ type Meta struct { // a gate a person opened from inside a conversation must be open when they // come back to it. Approval string `json:"approval,omitempty"` + // Scope is conversation when the model is told only about its own work. + // Project or absence keeps the project's reading of other conversations' work. + Scope string `json:"scope,omitempty"` // Created is when the session was minted. Created time.Time `json:"created"` // LastUserAt is when the PERSON last said something. Resume order is on @@ -513,6 +516,9 @@ func saveMetaLocked(dir string, meta Meta) error { } meta.ChatURI = current.ChatURI } + if meta.Scope == "" { + meta.Scope = current.Scope + } meta.AgentIdentity = keptAgentIdentity(current, meta) meta.AgentIdentity, err = protectAgentIdentity(meta.AgentIdentity) if err != nil { diff --git a/internal/session/scope.go b/internal/session/scope.go new file mode 100644 index 0000000000..883d77834c --- /dev/null +++ b/internal/session/scope.go @@ -0,0 +1,61 @@ +package session + +import ( + "fmt" + "strings" +) + +const ( + WorkScopeProject = "project" + WorkScopeConversation = "conversation" +) + +// ConversationScopeAt reads the stored scope. An absent or unreadable identity +// keeps the project's ordinary reading of other conversations' work. +func ConversationScopeAt(dir string) string { + if strings.TrimSpace(dir) == "" { + return WorkScopeProject + } + meta, err := LoadMeta(dir) + if err == nil && meta.Scope == WorkScopeConversation { + return WorkScopeConversation + } + return WorkScopeProject +} + +// SetConversationScope changes only an existing conversation's scope. A word +// already kept, or project when no scope is stored, owes no write. +func SetConversationScope(dir, scope string) (changed bool, err error) { + if scope != WorkScopeProject && scope != WorkScopeConversation { + return false, fmt.Errorf("scope is project or conversation") + } + dir = strings.TrimSpace(dir) + if dir == "" { + return false, fmt.Errorf("set conversation scope: no conversation directory") + } + err = withMetaLock(dir, func() error { + meta, err := LoadMeta(dir) + if err != nil { + return err + } + if meta.ID == "" { + return fmt.Errorf("no conversation at %s", dir) + } + if meta.Scope == scope || (meta.Scope == "" && scope == WorkScopeProject) { + return nil + } + meta.Scope = scope + if err := saveMetaLocked(dir, meta); err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +// ownWorkOnly is read beside the task index, so a stored choice reaches a live +// conversation without another connection or a read on the request path. +func (a *Agent) ownWorkOnly() bool { + return ConversationScopeAt(a.config.Place.Dir) == WorkScopeConversation +} diff --git a/internal/session/scope_test.go b/internal/session/scope_test.go new file mode 100644 index 0000000000..6d7750fe31 --- /dev/null +++ b/internal/session/scope_test.go @@ -0,0 +1,502 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +// scopeRig uses the same index and presence records as the turn-context and +// tasks-tool fixtures, with an own row and another project to catch wider reads. +func scopeRig(t *testing.T) (*Agent, string) { + t.Helper() + root := t.TempDir() + bucket := filepath.Join(root, "here") + dir := filepath.Join(bucket, "0123456789abcdef") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = Place{Dir: dir, Workspace: c.Workspace} + c.SessionFile = c.Place.Transcript() + }) + if err := SaveMeta(dir, Meta{ID: "0123456789abcdef", Workspace: agent.config.Workspace}); err != nil { + t.Fatal(err) + } + writeWindow(t, bucket, "theirs", "Other conversation", time.Second, + PresenceTask{ID: "4", Title: "Sweep the call sites", State: string(TaskRunning), + Files: []string{"internal/session/agent.go"}}) + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("99", "theirs", "Fix the nil-map crash", time.Now().Add(-time.Minute))) + agent.mu.Lock() + id := agent.sessionID() + agent.mu.Unlock() + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("7", id, "Repair this parser", time.Now().Add(-2*time.Minute))) + machineSession(t, root, "away", "another", "Separate project", t.TempDir(), 4242, time.Second, + PresenceTask{ID: "8", Title: "Separate project work", State: string(TaskRunning)}) + return agent, dir +} + +// L-FIX9-cross A1 +func TestConversationScopeLeavesOtherWorkOutOfTurnContext(t *testing.T) { + agent, dir := scopeRig(t) + if changed, err := SetConversationScope(dir, WorkScopeConversation); err != nil || !changed { + t.Fatalf("set scope = %v, %v", changed, err) + } + agent.refreshElsewhere(context.Background()) + if got := volatileNote(agent); strings.Contains(got, "") { + t.Fatalf("other work reached the turn: %s", got) + } + if agent.elsewhereText != "" || len(agent.elsewhereTold) != 0 { + t.Fatalf("other work was retained: %q, %+v", agent.elsewhereText, agent.elsewhereTold) + } + agent.SettleWrites() + if !LastTold(dir).IsZero() { + t.Fatal("a conversation told no other work advanced its told stamp") + } +} + +// L-FIX9-cross A2 +func TestConversationScopeTasksReachOnlyItsOwnRows(t *testing.T) { + agent, dir := scopeRig(t) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + for _, args := range []string{`{}`, `{"scope":"everywhere"}`, `{"query":"nil-map"}`} { + text, isError := runTool(t, agent, "tasks", args) + if isError { + t.Fatalf("search %s = %s", args, text) + } + for _, absent := range []string{"Fix the nil-map crash", "Sweep the call sites", "Separate project work", "running in other codeaf windows", "running in other projects"} { + if strings.Contains(text, absent) { + t.Fatalf("search %s reached %q: %s", args, absent, text) + } + } + if args != `{"query":"nil-map"}` && !strings.Contains(text, "Repair this parser") { + t.Fatalf("own work is missing: %s", text) + } + } + for _, args := range []string{ + `{"id":99}`, `{"id":"fix-the-nil-map-crash"}`, + `{"id":99,"say":"change the guard"}`, `{"id":99,"resolve":"accept"}`, + } { + text, isError := runTool(t, agent, "tasks", args) + if !isError || !strings.Contains(text, "No task") || strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("other task %s = %s (error %v)", args, text, isError) + } + } + text, isError := runTool(t, agent, "tasks", `{"id":7}`) + if isError || !strings.Contains(text, "Repair this parser") { + t.Fatalf("own task read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's task index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A3 +func TestProjectScopeKeepsOtherWorkVisible(t *testing.T) { + for _, reset := range []bool{false, true} { + t.Run(map[bool]string{false: "absent", true: "restored"}[reset], func(t *testing.T) { + agent, dir := scopeRig(t) + if reset { + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + if _, err := SetConversationScope(dir, WorkScopeProject); err != nil { + t.Fatal(err) + } + } + agent.refreshElsewhere(context.Background()) + for _, want := range []string{"", "Fix the nil-map crash", "Sweep the call sites"} { + if got := volatileNote(agent); !strings.Contains(got, want) { + t.Fatalf("project context lacks %q: %s", want, got) + } + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") || !strings.Contains(text, "running in other codeaf windows") { + t.Fatalf("project tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Separate project work") { + t.Fatalf("project wide tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":99}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("project task read = %s (error %v)", text, isError) + } + }) + } +} + +// L-FIX9-cross A5 +func TestConversationScopeClearsAnEarlierBlockOnTheNextReading(t *testing.T) { + agent, dir := scopeRig(t) + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + if !strings.Contains(volatileNote(agent), "") || len(agent.elsewhereTold) == 0 { + t.Fatal("the fixture never delivered other work") + } + was := LastTold(dir) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + agent.mu.Lock() + current := agent.volatileBlockLocked() + agent.mu.Unlock() + if agent.elsewhereText != "" || agent.elsewhereTold != nil || strings.Contains(current, "") { + t.Fatalf("earlier work survived the next reading: %s", current) + } + if !LastTold(dir).Equal(was) { + t.Fatal("clearing the block advanced the told stamp") + } +} + +// L-FIX9-cross A6 +// LoadMeta reports the same facts except scope and the build stamped on every +// metadata write; the write applies the usual metadata normalisation. +func TestConversationScopeChangesOnlyItsStoredWord(t *testing.T) { + dir := t.TempDir() + meta := Meta{ + ID: "0123456789abcdef", Workspace: t.TempDir(), Title: "Parser repairs", + Model: "test/model", Effort: "high", Approval: PostureAsk, + SpentUSD: .25, Tokens: 140, Created: time.Now().Add(-time.Hour).UTC(), + LastUserAt: time.Now().Add(-time.Minute).UTC(), Archived: true, + Places: []PlaceRef{{Path: t.TempDir()}}, + } + if err := SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + transcript := (Place{Dir: dir}).Transcript() + journal := []byte("{\"type\":\"session\",\"id\":\"0123456789abcdef\"}\n") + if err := os.WriteFile(transcript, journal, 0o600); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + changed, err := SetConversationScope(dir, word) + if err != nil || !changed || ConversationScopeAt(dir) != word { + t.Fatalf("set %q = %v, %v; stored %q", word, changed, err, ConversationScopeAt(dir)) + } + after := scopeMetaFields(t, dir) + if string(after["scope"]) != strconv.Quote(word) { + t.Fatalf("scope = %s, want %q", after["scope"], word) + } + delete(after, "scope") + if !reflect.DeepEqual(before, after) { + t.Fatalf("scope changed other metadata: before %v, after %v", before, after) + } + got, err := os.ReadFile(transcript) + if err != nil || !bytes.Equal(got, journal) { + t.Fatalf("scope changed the transcript: %q, %v", got, err) + } + + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + was, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + changed, err = SetConversationScope(dir, word) + if err != nil || changed { + t.Fatalf("repeat %q = %v, %v", word, changed, err) + } + now, err := os.Stat(path) + if err != nil || !os.SameFile(was, now) || !was.ModTime().Equal(now.ModTime()) { + t.Fatalf("repeat %q rewrote meta.json: %v", word, err) + } + } +} + +// L-FIX9-cross A6 +func TestProjectScopeLeavesAbsentScopeUnwritten(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if changed, err := SetConversationScope(dir, WorkScopeProject); err != nil || changed { + t.Fatalf("set absent project = %v, %v", changed, err) + } + after, err := os.Stat(path) + if err != nil || !os.SameFile(before, after) || !before.ModTime().Equal(after.ModTime()) { + t.Fatalf("default project rewrote meta.json: %v", err) + } + if _, present := scopeMetaFields(t, dir)["scope"]; present { + t.Fatal("default project wrote a scope") + } +} + +func scopeMetaFields(t *testing.T, dir string) map[string]json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, placeMeta)) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + return fields +} + +// L-FIX9-cross A8 +func TestConversationScopeRefusesUnknownWordsAndMissingConversations(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Workspace: t.TempDir()}); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{"", "everywhere", "Conversation", " conversation "} { + if changed, err := SetConversationScope(dir, word); err == nil || changed { + t.Fatalf("unknown %q = %v, %v", word, changed, err) + } + } + if !reflect.DeepEqual(before, scopeMetaFields(t, dir)) { + t.Fatal("an unknown word changed metadata") + } + for _, dir := range []string{"", " ", t.TempDir(), filepath.Join(t.TempDir(), "absent")} { + if changed, err := SetConversationScope(dir, WorkScopeConversation); err == nil || changed { + t.Fatalf("no conversation at %q = %v, %v", dir, changed, err) + } + if dir != "" && dir != " " { + if _, err := os.Stat(filepath.Join(dir, placeMeta)); !os.IsNotExist(err) { + t.Fatalf("a missing conversation acquired meta.json: %v", err) + } + } + } +} + +// L-FIX9-cross A3 +func TestConversationScopeDefaultsQuietlyToProject(t *testing.T) { + for _, stored := range []string{"", "project", "Conversation", "elsewhere", WorkScopeConversation} { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Scope: stored}); err != nil { + t.Fatal(err) + } + want := WorkScopeProject + if stored == WorkScopeConversation { + want = WorkScopeConversation + } + if got := ConversationScopeAt(dir); got != want { + t.Fatalf("stored %q reads %q, want %q", stored, got, want) + } + } + for _, raw := range []string{"", "{broken", `{"scope":"conversation"}`} { + dir := t.TempDir() + if raw != "" { + if err := os.WriteFile(filepath.Join(dir, placeMeta), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("unreadable identity %q reads %q", raw, got) + } + } + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, placeMeta), 0o700); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("failed metadata read = %q", got) + } + if got := ConversationScopeAt(""); got != WorkScopeProject { + t.Fatalf("no folder = %q", got) + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesReloadAndLaterMetadataStamps(t *testing.T) { + agent, dir := scopeRig(t) + stale := agent.metaSnapshot() + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + place := t.TempDir() + for _, stamp := range []struct { + name string + write func() + }{ + {"effort", func() { + if !agent.SetConversationEffort("high") { + t.Fatal("effort was refused") + } + }}, + {"title", func() { agent.stampTitle("Parser repairs") }}, + {"spend", func() { agent.writeSpendSnapshot(dir, stale, .25, 140) }}, + {"places", func() { + agent.mu.Lock() + agent.places = []PlaceRef{{Path: place, Arrival: PlaceSaid}} + agent.mu.Unlock() + agent.stampPlaces() + }}, + {"trees", agent.stampTrees}, + {"model", agent.stampMeta}, + {"approval", func() { + agent.config.ApprovalGate = &fakeApprovalGate{standing: PostureAsk} + if err := agent.SetApprovalPosture(PostureAllow); err != nil { + t.Fatal(err) + } + }}, + {"user", func() { + agent.mu.Lock() + agent.stampUserLocked("Keep the parser repair") + agent.mu.Unlock() + }}, + } { + stamp.write() + agent.SettleWrites() + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != WorkScopeConversation || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatalf("%s lost scope: %+v, %v", stamp.name, meta, err) + } + } + meta, err := LoadMeta(dir) + if err != nil || meta.Effort != "high" || meta.Title != "Parser repairs" || meta.SpentUSD != .25 || meta.Approval != PostureAllow || len(meta.Places) != 1 || meta.Places[0].Path != place { + t.Fatalf("the later stamps did not land: %+v, %v", meta, err) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + reopened, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = agent.config.Workspace + c.Place = agent.config.Place + c.SessionFile = c.Place.Transcript() + }) + reopened.refreshElsewhere(context.Background()) + if strings.Contains(volatileNote(reopened), "") || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatal("a reopened conversation forgot its scope") + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesStaleWholeMetadataSaves(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + stale, err := LoadMeta(dir) + if err != nil || stale.Scope != "" { + t.Fatalf("initial metadata = %+v, %v", stale, err) + } + stale.Title = "Parser repairs" + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + if changed, err := SetConversationScope(dir, word); err != nil || !changed { + t.Fatalf("set %q = %v, %v", word, changed, err) + } + if err := SaveMeta(dir, stale); err != nil { + t.Fatal(err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != word || ConversationScopeAt(dir) != word || meta.Title != stale.Title { + t.Fatalf("stale save after %q = %+v, %v", word, meta, err) + } + } +} + +// L-FIX9-cross A2 +func TestConversationScopeKeepsItsLiveGraphWhileFilteringTheMergedIndex(t *testing.T) { + agent, _, id := runningStubbedNode(t, "Keep my live task") + dir := filepath.Join(t.TempDir(), "mine") + agent.mu.Lock() + agent.config.Place = Place{Dir: dir, Workspace: agent.config.Workspace} + agent.mu.Unlock() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + number := strconv.FormatUint(id, 10) + appendTaskIndex(agent.config.taskIndexFile(), + deltaLandedRow(number, "theirs", "An unrelated newer task", time.Now())) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged listing = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":`+number+`}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's merged index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A2 +func TestConversationScopeLeavesATaskNodesChildrenVisible(t *testing.T) { + owner, dir := scopeRig(t) + graph := stubbedGraph(owner, func(*TaskNode) {}) + parent := graph.reserve() + graph.admit(parent, taskSpec{title: "Parent task", named: true, brief: "b", acceptance: "a"}) + child := graph.reserve() + graph.admit(child, taskSpec{title: "Its own piece", named: true, brief: "b", acceptance: "a", parent: parent}) + worker, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = owner.config.Place + c.InTask, c.taskID, c.tasker = true, parent, graph + }) + for _, scope := range []string{WorkScopeProject, WorkScopeConversation} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, worker, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Its own piece") || strings.Contains(text, "Parent task") || strings.Contains(text, "Fix the nil-map crash") || strings.Contains(text, "Separate project work") { + t.Fatalf("node under %q = %s (error %v)", scope, text, isError) + } + worker.refreshElsewhere(context.Background()) + if worker.elsewhereText != "" { + t.Fatalf("node under %q received other work: %s", scope, worker.elsewhereText) + } + } +} + +// L-FIX9-cross A6 +func TestConversationScopeStaysInMetaBesideSealedConversationSettings(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, cellStateDir), 0o700); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: "mine", Model: "test/model", Effort: "high", Approval: PostureAsk}); err != nil { + t.Fatal(err) + } + path := layoutOf(dir).metaTruth(dir) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{WorkScopeConversation, WorkScopeProject} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != scope { + t.Fatalf("stored scope = %q, want %q", got, scope) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed sealed settings: %q, %v", after, err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Model != "test/model" || meta.Effort != "high" || meta.Approval != PostureAsk { + t.Fatalf("scope changed conversation settings: %+v, %v", meta, err) + } + } +} diff --git a/internal/session/taskdelta.go b/internal/session/taskdelta.go index 3e9463177e..4944b166b7 100644 --- a/internal/session/taskdelta.go +++ b/internal/session/taskdelta.go @@ -591,6 +591,18 @@ func (a *Agent) refreshElsewhere(ctx context.Context) { mine := a.sessionID() a.mu.Unlock() + // A conversation scoped to its own work drops an earlier block too, so the + // next reading follows the stored choice without advancing the told stamp. + if a.ownWorkOnly() { + a.mu.Lock() + if !a.closed { + a.elsewhereTold = nil + a.elsewhereText = "" + } + a.mu.Unlock() + return + } + dir := strings.TrimSpace(a.config.Place.Dir) now := time.Now() since := LastTold(dir) diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index fb0820c213..34baf90ea5 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -375,7 +375,7 @@ func (a *Agent) taskSearchText(query string, limit int, scope string) string { // THIS CONVERSATION'S OWN RUNS ARE LEFT TO THEIR STORE'S LISTING, which the // tool puts above this one ([Agent.withoutOwnRunRows]). out := taskRowsTextLimit(a.withoutOwnRunRows(a.taskRows()), query, limit) - if !a.tellsElsewhere() { + if !a.tellsElsewhere() || a.ownWorkOnly() { return a.taskConversationHint(out) } now := time.Now() @@ -1079,8 +1079,8 @@ func taskWhereWord(entry TaskIndexEntry) string { return taskWhereNowhere } -// taskRows is what this tool answers from: the project's whole index in a -// conversation, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. +// taskRows is what this tool answers from: the project's index or only this +// conversation's own rows, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. // // The scope is applied here, at the one door, rather than in each of the three // ops: a search, a read, a steer and a resolve are four things to do with a @@ -1088,7 +1088,20 @@ func taskWhereWord(entry TaskIndexEntry) string { func (a *Agent) taskRows() []TaskIndexEntry { parent := a.config.taskID if parent == 0 { - return a.TaskIndex() + rows := a.TaskIndex() + if a.config.InTask || !a.ownWorkOnly() { + return rows + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + own := make([]TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if row.SessionID == session { + own = append(own, row) + } + } + return own } a.mu.Lock() session := a.sessionID() @@ -1118,9 +1131,9 @@ func (a *Agent) taskRows() []TaskIndexEntry { // left to settle it in" and pointed at a stranger's worktree — over a node this // session was holding all along. // -// A NAME STILL MEANS THE NEWEST. A slug is derived from a title and carries no -// conversation in it, so "the nil-map task" said out loud means the last one -// whoever ran it, exactly as it always did. +// A NAME STILL MEANS THE NEWEST VISIBLE ROW. A slug is derived from a title +// and carries no conversation in it, so the scope of the rows decides whose +// "nil-map task" a name can reach. // // AND A NODE ASKS FOR NOTHING WIDER THAN ITS OWN FAMILY. Inside a node // [Agent.taskRows] is already only the pieces it handed out, and reaching into From ecb0e6aca6fe90dafdb4ca197039369ee6499591 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 12:07:48 -0500 Subject: [PATCH 5/8] changes: one entry for client tools and conversation scope (#2613) Replaces the five entries carried with the source commits. Co-Authored-By: Claude Mythos 5.2 --- docs/changes/unreleased/2334-client-tools.md | 24 ---------- .../2346-client-tool-source-message.md | 16 ------- .../2360-client-tool-answer-wait-name.md | 11 ----- .../unreleased/2417-client-tools-remote.md | 24 ---------- docs/changes/unreleased/2462-agent-scope.md | 18 -------- docs/changes/unreleased/2613-client-tools.md | 44 +++++++++++++++++++ 6 files changed, 44 insertions(+), 93 deletions(-) delete mode 100644 docs/changes/unreleased/2334-client-tools.md delete mode 100644 docs/changes/unreleased/2346-client-tool-source-message.md delete mode 100644 docs/changes/unreleased/2360-client-tool-answer-wait-name.md delete mode 100644 docs/changes/unreleased/2417-client-tools-remote.md delete mode 100644 docs/changes/unreleased/2462-agent-scope.md create mode 100644 docs/changes/unreleased/2613-client-tools.md diff --git a/docs/changes/unreleased/2334-client-tools.md b/docs/changes/unreleased/2334-client-tools.md deleted file mode 100644 index b08d04aa68..0000000000 --- a/docs/changes/unreleased/2334-client-tools.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -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. diff --git a/docs/changes/unreleased/2346-client-tool-source-message.md b/docs/changes/unreleased/2346-client-tool-source-message.md deleted file mode 100644 index 2676eba4c7..0000000000 --- a/docs/changes/unreleased/2346-client-tool-source-message.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -kind: added -title: A client tool call names the message its turn answers -pr: 2346 -surface: [app, chat] -invalidates: [] ---- - -`agents.tool_call` carries an optional `source_message_id`: the durable id of -the message the running turn answers, the same string as `Entry.message_id` -on that message's chat entry. It is captured when the turn starts, so it is the -same after an engine restart and on a replayed call. A stopped turn asked again -carries its recorded message. Turns the engine starts itself, including a -carry-on after a quit or close, carry none. The hello feature -`agents.client_tools.source` is advertised with `agents.client_tools`. `turn` -remains the process-local counter. diff --git a/docs/changes/unreleased/2360-client-tool-answer-wait-name.md b/docs/changes/unreleased/2360-client-tool-answer-wait-name.md deleted file mode 100644 index abb3050079..0000000000 --- a/docs/changes/unreleased/2360-client-tool-answer-wait-name.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -kind: internal -title: Client tools name their answer cutoff so the leaf-room law reads it as a wait -pr: 2360 -surface: [app] -invalidates: [] ---- - -The pending client-tool answer timestamp is named `answerBy`, and its selected -millisecond limit is named `waitMS`. The client's effective time limit, emitted -`DeadlineMS`, wait timer and expiry checks are unchanged. diff --git a/docs/changes/unreleased/2417-client-tools-remote.md b/docs/changes/unreleased/2417-client-tools-remote.md deleted file mode 100644 index 9d3a54c23a..0000000000 --- a/docs/changes/unreleased/2417-client-tools-remote.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -kind: added -title: Client tools reach conversations held by another codeaf or on another computer -pr: 2417 -surface: [app, remote, chat] -invalidates: - - The app answered bad_scope to agents.tools_set for a conversation another codeaf host held or one held on another computer; it now forwards the tools when the holder supports it and stores them for a chat held elsewhere. ---- - -**Client tools across the remote door.** When the app joins a conversation that -another codeaf host already holds, `agents.tools_set` sends the agent's sets to -that holder. Its calls come back as `agents.tool_call` on the app connection -that set them, and `agents.tool_result` answers go back the same way. The app -pushes the sets again every time it joins the conversation, and a roaming -connection re-sends them after a redial. - -A connection that leaves loses its sets, and its pending calls end at once with -the existing disconnected answer. A call nobody answers still ends at its wait. -Two connections on one conversation keep separate sets and never receive each -other's calls. - -A chat held on another computer keeps its sets until it runs here again. The app -hello adds `agents.client_tools.remote`. A holder from an older build is still -answered with `bad_scope`, and the remote protocol version is unchanged. diff --git a/docs/changes/unreleased/2462-agent-scope.md b/docs/changes/unreleased/2462-agent-scope.md deleted file mode 100644 index c05b340d01..0000000000 --- a/docs/changes/unreleased/2462-agent-scope.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -kind: added -title: A conversation can be told only about its own work (agents.set_scope) -pr: 2462 -surface: [app, engine] -invalidates: - - "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and restores the earlier behavior." ---- - -`agents.set_scope {id, scope}` stores `project` or `conversation` and answers the -stored word. Engines serving it announce `agents.scope`. The choice survives a -restart and takes effect on the next turn or tasks call without opening, -warming or interrupting the conversation. Its folder, transcript, title, model -and tasks stay as they were. A lent conversation or one without a local folder -is refused. - -The person's task list, task nodes and the write-safety checks keep their -existing behavior. diff --git a/docs/changes/unreleased/2613-client-tools.md b/docs/changes/unreleased/2613-client-tools.md new file mode 100644 index 0000000000..c3b4d861ae --- /dev/null +++ b/docs/changes/unreleased/2613-client-tools.md @@ -0,0 +1,44 @@ +--- +kind: added +title: Apps can attach their own tools to a conversation and tell it only about its own work +pr: 2613 +surface: [app, engine, remote, chat] +invalidates: + - "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and keeps the earlier behavior." +--- + +**Client tools.** `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 wait (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`. + +`agents.tool_call` carries an optional `source_message_id`: the durable id of +the message the running turn answers, the same string as `Entry.message_id` on +that message's chat entry. It is the same after an engine restart and on a +replayed call; turns the engine starts itself carry none. The hello feature +`agents.client_tools.source` is advertised with `agents.client_tools`. + +**Across the remote door.** When the app joins a conversation that another +codeaf host already holds, `agents.tools_set` sends the agent's sets to that +holder. Its calls come back on the app connection that set them, and answers go +back the same way. A connection that leaves loses its sets, and its pending +calls end at once. Two connections on one conversation keep separate sets. A +chat held on another computer keeps its sets until it runs here again. The app +hello adds `agents.client_tools.remote`. A holder from an older build is +answered with `bad_scope`, and the remote protocol version is unchanged. + +**Conversation scope.** `agents.set_scope {id, scope}` stores `project` or +`conversation` and answers the stored word. Engines serving it announce +`agents.scope`. The choice survives a restart and takes effect on the next turn +or tasks call without opening, warming or interrupting the conversation. A lent +conversation or one without a local folder is refused. The person's task list, +task nodes and the write-safety checks keep their existing behavior. + +This carries #2334, #2346, #2360, #2417 and #2462 from the closed integration +pull request #2306. Nothing specific to Workstreams came with it: the desktop +app keeps Workstreams itself and uses these two generic pieces. From f1908201ad50fefee26ba292365190aad5e79712 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 18:21:17 -0500 Subject: [PATCH 6/8] Client tools: a call id hashes the whole call, and a call without a provider id gets a random id The `call` id was sha256(agent, provider call id), with a per-process counter when the provider gave no id. Provider ids are not unique: Kimi-family models write their own `name:idx` ids, which repeat after agents.rewind or compaction, and a faulty server can repeat any id. A reused id with another tool or other arguments therefore joined the earlier call's wait or got its saved answer, and never reached the app, while the app's 30-day table replayed the old answer for the same id. The counter restarted at every launch, so ids minted without a provider id came back after a restart. clientToolCallID now hashes the agent, provider call id, set, tool name and exact argument bytes, each length-prefixed, and keeps the `ct_` + 64 hex shape. The same call dispatched again keeps its id, joins the pending wait and gets the saved answer. Without a provider id the id is 32 random bytes. Calls from a remote holder reach the same door, so they share the identity. PROTOCOL.md and the change entry say what `call` is derived from. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appclienttools.go | 39 ++++- cmd/codeaf/appclienttools_remote_test.go | 42 +++++- cmd/codeaf/appclienttools_test.go | 151 ++++++++++++++++++- docs/changes/unreleased/2613-client-tools.md | 7 +- docs/design/app-v3/PROTOCOL.md | 12 +- 5 files changed, 233 insertions(+), 18 deletions(-) diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go index 9695a186c0..6145eb44d8 100644 --- a/cmd/codeaf/appclienttools.go +++ b/cmd/codeaf/appclienttools.go @@ -3,7 +3,9 @@ package main import ( "bytes" "context" + "crypto/rand" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -21,7 +23,6 @@ const clientToolSettledTTL = 10 * time.Minute const clientToolSettledMax = 1024 var appClientToolsBackend atomic.Pointer[appBackend] -var clientToolFreshID atomic.Uint64 type clientToolAgentSets struct { version uint64 @@ -258,12 +259,34 @@ func (d *appClientToolDoor) ClientToolSets() (uint64, []session.ClientToolSet) { 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[:]) +// clientToolCallID names one model call to the app as `ct_` and 64 hex digits. +// +// The hash covers the whole call: the agent, the provider's call id, the set, +// the tool name and the exact argument bytes. The same call dispatched again, +// in this process or after a restart, keeps its id, so it joins the pending +// wait or gets the saved answer. A provider id alone is not a call's identity: +// Kimi-family models write their own `name:idx` ids, which repeat after a +// rewind or a compaction, and a faulty server can repeat any id. A reused id +// with another tool or other arguments is therefore a new call that reaches +// the app. Each field is length-prefixed, so no two calls share one input. +// +// Without a provider id nothing can recognise a redispatch, and the app keeps +// answered ids for 30 days, far longer than one process lives. The id is +// random rather than counted, so no later launch mints it again. +func clientToolCallID(agent string, call session.ClientToolCall) string { + if call.TurnCall == "" { + var random [sha256.Size]byte + _, _ = rand.Read(random[:]) + return "ct_" + hex.EncodeToString(random[:]) + } + sum := sha256.New() + for _, field := range [][]byte{[]byte(agent), []byte(call.TurnCall), []byte(call.Set), []byte(call.Name), call.Args} { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(field))) + sum.Write(size[:]) + sum.Write(field) + } + return "ct_" + hex.EncodeToString(sum.Sum(nil)) } func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { @@ -274,7 +297,7 @@ func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.Cli 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) + id := clientToolCallID(d.agent, call) r := &d.backend.clientTools pending, answer, emit := r.begin(ctx, d.agent, id, call) if pending == nil { diff --git a/cmd/codeaf/appclienttools_remote_test.go b/cmd/codeaf/appclienttools_remote_test.go index e579d9e58e..947abe37e3 100644 --- a/cmd/codeaf/appclienttools_remote_test.go +++ b/cmd/codeaf/appclienttools_remote_test.go @@ -178,7 +178,8 @@ func TestClientToolsRemoteJoinedModelRoundTrip(t *testing.T) { } finished: call := clientToolAwait(t, rig.emitter.seen) - if call.Call != clientToolCallID(rig.door.agent, "provider-remote-1") || call.TurnCall != "provider-remote-1" || call.SourceMessageID == "" { + modelCall := session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "provider-remote-1", Args: json.RawMessage(`{}`)} + if call.Call != clientToolCallID(rig.door.agent, modelCall) || call.TurnCall != "provider-remote-1" || call.SourceMessageID == "" { t.Fatalf("call identity: %+v", call) } matchedMessage := false @@ -273,6 +274,45 @@ func TestClientToolsRemoteBoundedResult(t *testing.T) { } } +// C2: the holder's calls reach the app's registry under the same call identity +// as local calls, so a provider id the holder's model reuses for other +// arguments is a new call here too, and the same call again is still one call. +func TestClientToolsRemoteReusedProviderIDWithOtherArgumentsIsANewCall(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + call := clientToolAttach(t, rig.backend, rig.door) + rig.emitter.answer = func(event appwire.AgentsToolCall) { + result, _ := json.Marshal(string(event.Args)) + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: result}) + if err != nil || !ack.Accepted { + t.Errorf("answer: %+v %v", ack, err) + } + } + through := func(call session.ClientToolCall) session.ClientToolAnswer { + t.Helper() + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(context.Background(), call) + answer <- got + }() + return clientToolAwait(t, answer) + } + other := call + other.Args = json.RawMessage(`{"id":4}`) + for _, step := range []struct { + call session.ClientToolCall + want string + }{{call, `"{\"id\":3}"`}, {other, `"{\"id\":4}"`}, {call, `"{\"id\":3}"`}} { + if got := through(step.call); got.IsError || got.Text != step.want { + t.Fatalf("holder call %s answered %+v, want %s", step.call.Args, got, step.want) + } + } + rig.emitter.mu.Lock() + defer rig.emitter.mu.Unlock() + if len(rig.emitter.calls) != 2 || rig.emitter.calls[0].Call == rig.emitter.calls[1].Call { + t.Fatalf("two distinct holder calls emitted: %+v", rig.emitter.calls) + } +} + // C4: every hold sends the current registry, without another tools_set. func TestClientToolsRemoteReholdPushesSets(t *testing.T) { rig := clientToolRemoteBackend(t, "") diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go index 41d19415b6..9dcc709cfe 100644 --- a/cmd/codeaf/appclienttools_test.go +++ b/cmd/codeaf/appclienttools_test.go @@ -7,8 +7,10 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "reflect" + "regexp" "strings" "sync" "testing" @@ -478,7 +480,7 @@ func TestClientToolsEventResultAndReplay(t *testing.T) { 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) || event.SourceMessageID != call.SourceMessage { + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call) || event.SourceMessageID != call.SourceMessage { t.Fatalf("event shape: %+v", event) } second := clientToolStart(context.Background(), door, call) @@ -502,7 +504,7 @@ func TestClientToolsEventResultAndReplay(t *testing.T) { if len(emitter.calls) != 1 { t.Fatalf("%d events for one call", len(emitter.calls)) } - // A new registry represents a new process, with a different local counter. + // A new registry represents a new process, which must name the same call alike. fresh, freshDoor, freshEmitter := clientToolBackend(t) clientToolAttach(t, fresh, freshDoor) fresh.locked(func() { fresh.agents[freshDoor.agent].live.turn = 1 }) @@ -517,6 +519,123 @@ func TestClientToolsEventResultAndReplay(t *testing.T) { clientToolAwait(t, replayed) } +// Contract (b), (d): a provider id names a call only together with what was +// called. Kimi-family models write their own `name:idx` ids, which repeat after +// a rewind or a compaction, and a faulty server can repeat any id. A reused id +// with another tool or other arguments must reach the app as a call of its own, +// whether the earlier call is still waiting or already answered. +func TestClientToolsReusedProviderIDWithAnotherCallReachesTheApp(t *testing.T) { + for _, earlier := range []string{"settled", "pending"} { + t.Run(earlier, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if _, err := b.SetClientTools(clientToolParams(door.agent, "other", "app_other")); err != nil { + t.Fatal(err) + } + answer := func(event appwire.AgentsToolCall) string { + t.Helper() + text := event.Name + " " + string(event.Args) + result, _ := json.Marshal(text) + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: result}) + if err != nil || !ack.Accepted { + t.Fatalf("answer %+v: %+v %v", event, ack, err) + } + return string(result) + } + first := clientToolStart(context.Background(), door, call) + firstEvent := clientToolAwait(t, emitter.seen) + firstWant := "" + if earlier == "settled" { + firstWant = answer(firstEvent) + if got := clientToolAwait(t, first); got.IsError || got.Text != firstWant { + t.Fatalf("first answer: %+v", got) + } + } + otherArgs := call + otherArgs.Args = json.RawMessage(`{"id":4}`) + otherTool := session.ClientToolCall{Set: "other", Name: "app_other", TurnCall: call.TurnCall, Args: call.Args} + seen := map[string]bool{firstEvent.Call: true} + for _, reused := range []session.ClientToolCall{otherArgs, otherTool} { + done := clientToolStart(context.Background(), door, reused) + var event appwire.AgentsToolCall + select { + case event = <-emitter.seen: + case got := <-done: + t.Fatalf("%s %s with reused provider id %q never reached the app; the model was told %+v", + reused.Name, reused.Args, reused.TurnCall, got) + case <-time.After(2 * time.Second): + t.Fatalf("%s %s with reused provider id %q never reached the app: it joined the earlier call's wait", + reused.Name, reused.Args, reused.TurnCall) + } + if seen[event.Call] || event.Name != reused.Name || string(event.Args) != string(reused.Args) || event.TurnCall != reused.TurnCall { + t.Fatalf("reused provider id did not get its own call: %+v (earlier %v)", event, seen) + } + seen[event.Call] = true + want := answer(event) + if got := clientToolAwait(t, done); got.IsError || got.Text != want { + t.Fatalf("%s %s was answered %+v, want its own answer %s", reused.Name, reused.Args, got, want) + } + } + if earlier == "pending" { + firstWant = answer(firstEvent) + if got := clientToolAwait(t, first); got.IsError || got.Text != firstWant { + t.Fatalf("first answer: %+v", got) + } + } + // The same call dispatched again is still the same call. + if got := clientToolAwait(t, clientToolStart(context.Background(), door, call)); got.IsError || got.Text != firstWant { + t.Fatalf("true redispatch: %+v want %s", got, firstWant) + } + emitter.mu.Lock() + defer emitter.mu.Unlock() + if len(emitter.calls) != 3 { + t.Fatalf("three distinct calls emitted %d events: %+v", len(emitter.calls), emitter.calls) + } + }) + } +} + +// Contract (b): a call without a provider id gets a call id no later launch +// can mint again. The app keeps answered ids for 30 days, far longer than one +// engine process lives, so each child process below is one launch of the engine. +func TestClientToolsCallWithoutProviderIDNeverRepeatsAcrossLaunches(t *testing.T) { + const child = "CODEAF_TEST_CLIENT_TOOL_FRESH_ID" + if os.Getenv(child) == "1" { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + call.TurnCall = "" + ctx, cancel := context.WithCancel(context.Background()) + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + cancel() + clientToolAwait(t, done) + fmt.Printf("client-tool-call=%s\n", event.Call) + return + } + var ids []string + for launch := 0; launch < 2; launch++ { + command := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestClientToolsCallWithoutProviderIDNeverRepeatsAcrossLaunches$", "-test.v") + command.Env = append(os.Environ(), child+"=1") + out, err := command.CombinedOutput() + if err != nil { + t.Fatalf("launch %d: %v\n%s", launch+1, err, out) + } + match := regexp.MustCompile(`(?m)^client-tool-call=(\S+)$`).FindSubmatch(out) + if match == nil { + t.Fatalf("launch %d printed no call id:\n%s", launch+1, out) + } + ids = append(ids, string(match[1])) + } + if ids[0] == ids[1] { + t.Fatalf("two launches minted the same call id %s for different calls without a provider id", ids[0]) + } + for _, id := range ids { + if !regexp.MustCompile(`^ct_[0-9a-f]{64}$`).MatchString(id) { + t.Fatalf("call id %q is not ct_ and 64 hex digits", id) + } + } +} + // Contract (b): the pending entry exists before an emitter can answer synchronously. func TestClientToolsFastErrorAnswer(t *testing.T) { b, door, emitter := clientToolBackend(t) @@ -657,7 +776,7 @@ func TestClientToolsEndedQueuedCallsAreNotEmitted(t *testing.T) { queuedCtx, cancelQueued := context.WithCancel(context.Background()) defer cancelQueued() call.TurnCall = "queued-call" - id := clientToolCallID(door.agent, call.TurnCall) + id := clientToolCallID(door.agent, call) r := &b.clientTools r.mu.Lock() pending, _, emit := r.beginLocked(queuedCtx, door.agent, id, call) @@ -852,8 +971,30 @@ func TestClientToolsSettledMemoryBoundAndExpiry(t *testing.T) { 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") + call := session.ClientToolCall{Set: "s", Name: "n", TurnCall: "p", Args: json.RawMessage(`{}`), SourceMessage: "m", DeadlineMS: 1000} + shape := regexp.MustCompile(`^ct_[0-9a-f]{64}$`) + same := call + same.SourceMessage, same.DeadlineMS = "other message", 2000 + if id := clientToolCallID("a", call); !shape.MatchString(id) || id != clientToolCallID("a", same) { + t.Fatalf("redispatch identity: %q", id) + } + for _, change := range []func(*session.ClientToolCall) string{ + func(c *session.ClientToolCall) string { c.TurnCall = "q"; return "b" }, + func(c *session.ClientToolCall) string { return "b" }, + func(c *session.ClientToolCall) string { c.Set = "t"; return "a" }, + func(c *session.ClientToolCall) string { c.Name = "o"; return "a" }, + func(c *session.ClientToolCall) string { c.Args = json.RawMessage(`{"x":1}`); return "a" }, + func(c *session.ClientToolCall) string { c.Set, c.Name = "s\x00n", ""; return "a" }, + } { + other := call + if agent := change(&other); clientToolCallID(agent, other) == clientToolCallID("a", call) { + t.Fatalf("call identity collision: %+v", other) + } + } + fresh := call + fresh.TurnCall = "" + if id := clientToolCallID("a", fresh); !shape.MatchString(id) || id == clientToolCallID("a", fresh) { + t.Fatalf("fresh identity: %q", id) } } diff --git a/docs/changes/unreleased/2613-client-tools.md b/docs/changes/unreleased/2613-client-tools.md index c3b4d861ae..90b8da6ed9 100644 --- a/docs/changes/unreleased/2613-client-tools.md +++ b/docs/changes/unreleased/2613-client-tools.md @@ -13,7 +13,12 @@ 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 wait (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 +outcome is unknown. Late or repeated answers are ignored. The event's `call` id +hashes the whole call (agent, provider call id, set, tool and exact arguments), +so the same call dispatched again keeps its id and its saved answer, while a +provider id reused for another tool or other arguments is a new call that +reaches the client. A call without a provider id gets a random id that no later +launch repeats. 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`. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index b4028350e6..167d830757 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -179,9 +179,15 @@ an absent field means no such message, rather than an older engine. One model call emits exactly one `agents.tool_call`. `turn` is the engine's process-local conversation counter, which resets after restart; -`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 +`turn_call` matches `turn.tool.call`. `call` is `ct_` followed by 64 lowercase +hex digits. It is a hash of the whole call: the agent, the provider call id, the +set, the tool name and the exact argument bytes. The same call dispatched again +keeps its `call`, including after an engine restart. A provider call id alone +can repeat: Kimi-family models write their own `name:index` ids, which repeat +after `agents.rewind` or compaction, and a faulty server can repeat any id. So a +reused provider id with another tool or other arguments is a new call with its +own `call` and its own event. Without a provider id the engine mints a random +`call`, which no later launch repeats. 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 From 5e5efce5ce638abbfb52db856242cce7be7d4d2f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 18:32:14 -0500 Subject: [PATCH 7/8] Conversation scope: the page and the tasks description stop promising other windows' work Under `conversation` scope a conversation gets no block and its tasks tool lists only its own work, but its system prompt still said "OTHER codeaf WINDOWS ON THIS PROJECT ARE VISIBLE TO YOU" and the tasks description still said search includes other windows' live work. The model could read an empty answer as "nothing else is running here" and tell the person so. The page's other-windows line and the tasks description now follow the stored scope. Under conversation scope the page says the conversation is told only about its own work and must not conclude nothing else is running; the tasks description says search reaches only its own work, even with scope everywhere. A project page and tasks description stay byte for byte as before. The scope is read when the agent is made and again at each turn's start. A changed scope re-renders the page and replaces the tasks tool on the belt and on a lean shelf, in fresh arrays, so the model sees the change from the next turn, as the scope already promises. Task nodes keep their page. PROTOCOL.md, the tasks manual page and the change entry say so. Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2613-client-tools.md | 4 +- docs/design/app-v3/PROTOCOL.md | 8 +- internal/manual/chat/tasks.md | 4 +- internal/session/agent.go | 7 + internal/session/prompt.go | 5 + internal/session/scope.go | 87 +++++++++++ internal/session/scope_test.go | 143 +++++++++++++++++++ internal/session/session.go | 10 +- internal/session/tools_tasks.go | 16 ++- 9 files changed, 276 insertions(+), 8 deletions(-) diff --git a/docs/changes/unreleased/2613-client-tools.md b/docs/changes/unreleased/2613-client-tools.md index 90b8da6ed9..cb0d7fba14 100644 --- a/docs/changes/unreleased/2613-client-tools.md +++ b/docs/changes/unreleased/2613-client-tools.md @@ -40,7 +40,9 @@ answered with `bad_scope`, and the remote protocol version is unchanged. **Conversation scope.** `agents.set_scope {id, scope}` stores `project` or `conversation` and answers the stored word. Engines serving it announce `agents.scope`. The choice survives a restart and takes effect on the next turn -or tasks call without opening, warming or interrupting the conversation. A lent +or tasks call without opening, warming or interrupting the conversation. Under +`conversation` the model's instructions and its `tasks` tool description say +that other conversations' work is hidden, rather than promising it. A lent conversation or one without a local folder is refused. The person's task list, task nodes and the write-safety checks keep their existing behavior. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 167d830757..5f9e48e00e 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -89,11 +89,13 @@ landed and running work in its folder, as today. `conversation` means it is told only about its own work: its turns receive no other-conversation `` block, and its `tasks` tool lists and addresses only its own tasks. Neither the other-window running section nor the wider `scope: "everywhere"` section is -added. Task nodes keep their existing scope. +added. Its instructions and the `tasks` tool description say that other +conversations' work is hidden from it, instead of promising that work. Task +nodes keep their existing scope. The choice is stored with the conversation and survives restarts. It takes -effect from the conversation's next turn; a live tasks call also reads the -stored choice. Setting it never opens, warms or interrupts the conversation, +effect from the conversation's next turn, instructions and tool description +included; a live tasks call also reads the stored choice. Setting it never opens, warms or interrupts the conversation, never moves its folder, and never changes its transcript, title, model or tasks. The write-safety checks still warn before its own work writes over files other conversations are writing. The person's task list is unchanged. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 67ae561312..5e99407d70 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2909,7 +2909,9 @@ running work in the same folder. `conversation` means it is told only about its own work. No `` block is added to its turns, and the model's `tasks` tool lists only its own tasks. Another conversation's tasks cannot be read by name or id, steered or resolved through that tool. The running-in-other-windows -section and the `scope: "everywhere"` section are absent too. +section and the `scope: "everywhere"` section are absent too. Its instructions +and the `tasks` tool's description say other windows' work is hidden from it, +so it does not claim nothing else is running in the folder. The choice survives a restart and takes effect from the next turn; a tasks call also reads the saved choice. Setting it does not open or interrupt the chat, diff --git a/internal/session/agent.go b/internal/session/agent.go index dea13ae91a..6ed7fcc9ab 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -160,6 +160,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { config.liveProfile.launch = chosenPromptProfile(config) } config.liveProfile.current.Store(config.profile) + // What the page and the tasks description say about other windows follows + // the stored scope; a later change is taken at a turn's start (scope.go). + config.ownWork = config.readsOwnWorkOnly() system, own := config.System, false if strings.TrimSpace(system) == "" { system, own = renderSystem(config), true @@ -297,6 +300,7 @@ func newAgent(config Config, client Completer) (*Agent, error) { // hub has to exist before the tools are assembled (connect.go). agent.connect = newConnectHub(config) agent.initKnowledge() + agent.ownWorkTold.Store(config.ownWork) agent.tools = agent.belt() definitions, err := toolDefinitions(agent.tools) if err != nil { @@ -2027,6 +2031,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // (taskpresence.go). The nudge never blocks and never takes a lock, which is // what lets it sit under a.mu here. a.nudgePresence() + // AND WHAT THE PAGE SAYS ABOUT OTHER WINDOWS FOLLOWS A CHANGED SCOPE, from + // this turn on, as the scope's documentation promises (scope.go). + a.followWorkScopeLocked(time.Now()) // AND THE CLOCK IN THE PROMPT IS BROUGHT UP TO DATE BEFORE THE TURN OPENS, // when it has gone stale enough to be worth the cold prefix (prompt.go's // clockRefresh says why that is free). diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 4b8fd597a8..6c5951acf2 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -341,6 +341,9 @@ func renderSystemAt(config Config, now time.Time) string { // the shape; this conditions the sentences INSIDE one, which is where five // families of tools were being promised to workers that do not carry them. page := strings.TrimRight(promptWithBeltFacts(config), "\n") + // AND WHAT IT SAYS ABOUT OTHER WINDOWS FOLLOWS THE CONVERSATION'S SCOPE + // (scope.go); a project page is unchanged byte for byte. + page = config.scopedElsewhereFact(page) if _, err := shell.Bash(); err != nil { // Unix keeps its exact prompt bytes. On Windows an absent command // tool cannot inherit instructions that promise shell execution. @@ -613,6 +616,8 @@ func (a *Agent) liveModelConfigLocked() Config { if model := strings.TrimSpace(a.model); model != "" { config.Model = model } + // The page names the scope the tasks description was given (scope.go). + config.ownWork = a.ownWorkTold.Load() return config } diff --git a/internal/session/scope.go b/internal/session/scope.go index 883d77834c..a77d24906a 100644 --- a/internal/session/scope.go +++ b/internal/session/scope.go @@ -2,7 +2,12 @@ package session import ( "fmt" + "maps" + "slices" "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" ) const ( @@ -59,3 +64,85 @@ func SetConversationScope(dir, scope string) (changed bool, err error) { func (a *Agent) ownWorkOnly() bool { return ConversationScopeAt(a.config.Place.Dir) == WorkScopeConversation } + +// WHAT THE MODEL IS TOLD ABOUT OTHER WINDOWS FOLLOWS THE SCOPE. Under +// conversation scope no `` block arrives and the tasks tool lists +// only this conversation's work, so a page still promising other windows' +// work would let the model read an empty answer as "nothing else is running" +// and say so. elsewhereFactProject is prompts/system.md's own line, swapped at +// render time, so the project page stays byte for byte what it was. +const ( + elsewhereFactProject = "- OTHER codeaf WINDOWS ON THIS PROJECT ARE VISIBLE TO YOU: the final `` note lists LANDED files and RUNNING work. Read it before editing files another window touched." + elsewhereFactOwnWork = "- THIS CONVERSATION IS TOLD ONLY ABOUT ITS OWN WORK: other codeaf windows' landed files and running work are hidden from you, so never conclude that nothing else is running or changing on this project." +) + +// readsOwnWorkOnly reads the stored scope for the page and the tasks +// description. A task node is never told about other windows +// ([Agent.tellsElsewhere]), so its page is left as it was. +func (c Config) readsOwnWorkOnly() bool { + if c.InTask || c.taskID != 0 { + return false + } + return ConversationScopeAt(c.Place.Dir) == WorkScopeConversation +} + +func (c Config) scopedElsewhereFact(page string) string { + if !c.ownWork { + return page + } + return strings.Replace(page, elsewhereFactProject, elsewhereFactOwnWork, 1) +} + +// followWorkScopeLocked brings the page and the tasks description to the +// stored scope at a turn's start, the moment the scope takes effect, and does +// nothing while the scope is unchanged. A changed scope costs one cold prefix, +// as a changed placement does ([Agent.refreshClockLocked]). +// +// The caller holds a.mu; the belt is rewritten under armMu, in the order an +// anchoring conversation already takes them. +func (a *Agent) followWorkScopeLocked(now time.Time) { + own := a.config.readsOwnWorkOnly() + if own == a.ownWorkTold.Load() { + return + } + a.ownWorkTold.Store(own) + a.retellTasksTool() + a.rerenderSystemLocked(now) +} + +// retellTasksTool replaces the tasks tool where the belt or the shelf holds +// it, in fresh arrays, so a request walking the old snapshot is undisturbed. +func (a *Agent) retellTasksTool() { + a.armMu.Lock() + defer a.armMu.Unlock() + retold := a.tasksTool() + for i, tool := range a.tools { + if tool.Name != retold.Name { + continue + } + tools := slices.Clone(a.tools) + tools[i] = retold + if len(a.definitions) == len(a.tools) { + definition, err := toolDefinitions([]bare.Tool{retold}) + if err != nil { + return + } + definitions := slices.Clone(a.definitions) + definitions[i] = definition[0] + a.definitions = definitions + } + a.tools = tools + } + for name, group := range a.shelf { + for i, tool := range group { + if tool.Name != retold.Name { + continue + } + shelf := maps.Clone(a.shelf) + shelf[name] = slices.Clone(group) + shelf[name][i] = retold + a.shelf = shelf + return + } + } +} diff --git a/internal/session/scope_test.go b/internal/session/scope_test.go index 6d7750fe31..25e34e9bce 100644 --- a/internal/session/scope_test.go +++ b/internal/session/scope_test.go @@ -11,6 +11,8 @@ import ( "strings" "testing" "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" ) // scopeRig uses the same index and presence records as the turn-context and @@ -42,6 +44,147 @@ func scopeRig(t *testing.T) (*Agent, string) { return agent, dir } +// scopePromiseCompleter keeps the page and the tasks description of each +// request that carries the tasks tool, so errands beside a turn are ignored. +type scopePromiseCompleter struct { + scriptedCompleter + pages, tasks []string +} + +func (s *scopePromiseCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + for _, tool := range request.Tools { + if tool.Function.Name == "tasks" && len(messages) > 0 { + s.mu.Lock() + s.pages = append(s.pages, messageText(messages[0])) + s.tasks = append(s.tasks, tool.Function.Description) + s.mu.Unlock() + } + } + return s.scriptedCompleter.CompleteWithMessages(ctx, messages, options...) +} + +// The page and the tasks tool's description are what the model believes about +// other windows. Under conversation scope both must say that other work is +// hidden, so an empty answer is never read as "nothing else is running"; under +// project scope both keep their earlier words. The stored word decides from +// the next turn, both ways, and from the first turn of a reopened conversation. +func TestConversationScopeShapesWhatThePageAndTasksToolPromise(t *testing.T) { + const otherWindows = "OTHER codeaf WINDOWS ON THIS PROJECT ARE VISIBLE TO YOU" + const tasksOtherWindows = "Search includes other windows' live work as `another window`" + const ownWorkPage = "THIS CONVERSATION IS TOLD ONLY ABOUT ITS OWN WORK" + const ownWorkTasks = "Search lists only this conversation's own work" + if strings.Count(systemPrompt, otherWindows) != 1 || !strings.Contains(tasksDescription, tasksOtherWindows) { + t.Fatal("the project-scope sentences moved; this test would pass on nothing") + } + turn := func(agent *Agent, completer *scopePromiseCompleter, scope string) { + t.Helper() + completer.mu.Lock() + before := len(completer.pages) + completer.mu.Unlock() + events, err := agent.Submit(context.Background(), "what else is running?") + if err != nil { + t.Fatal(err) + } + collect(t, events) + completer.mu.Lock() + if len(completer.pages) == before { + completer.mu.Unlock() + t.Fatal("the turn sent no request carrying the tasks tool") + } + page, description := completer.pages[before], completer.tasks[before] + completer.mu.Unlock() + own := scope == WorkScopeConversation + for _, check := range []struct { + where, text, sentence string + want bool + }{ + {"page", page, otherWindows, !own}, + {"page", page, ownWorkPage, own}, + {"tasks description", description, tasksOtherWindows, !own}, + {"tasks description", description, ownWorkTasks, own}, + } { + if strings.Contains(check.text, check.sentence) != check.want { + t.Fatalf("%s scope: the %s says %q = %v, want %v:\n%s", scope, check.where, check.sentence, !check.want, check.want, check.text) + } + } + } + rig := func(place *Place, workspace string) (*Agent, *scopePromiseCompleter) { + t.Helper() + completer := &scopePromiseCompleter{} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.System, c.PromptProfile = "", "full" + if workspace != "" { + c.Workspace = workspace + } + c.Place = Place{Dir: place.Dir, Workspace: c.Workspace} + c.SessionFile = c.Place.Transcript() + }) + return agent, completer + } + place := &Place{Dir: filepath.Join(t.TempDir(), "here", "0123456789abcdef")} + agent, completer := rig(place, "") + if err := SaveMeta(place.Dir, Meta{ID: "0123456789abcdef", Workspace: agent.config.Workspace}); err != nil { + t.Fatal(err) + } + turn(agent, completer, WorkScopeProject) + if _, err := SetConversationScope(place.Dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + turn(agent, completer, WorkScopeConversation) + if _, err := SetConversationScope(place.Dir, WorkScopeProject); err != nil { + t.Fatal(err) + } + turn(agent, completer, WorkScopeProject) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if _, err := SetConversationScope(place.Dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + reopened, reopenedCompleter := rig(place, agent.config.Workspace) + turn(reopened, reopenedCompleter, WorkScopeConversation) +} + +// A lean conversation keeps `tasks` on a shelf. The copy it loads after the +// scope changed must say what a belt that always carried it says. +func TestConversationScopeReachesAShelvedTasksTool(t *testing.T) { + t.Setenv(promptProfileEnv, "") + dir := filepath.Join(t.TempDir(), "here", "0123456789abcdef") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.PromptProfile = "lean" + c.Place = Place{Dir: dir, Workspace: c.Workspace} + c.SessionFile = c.Place.Transcript() + }) + if !agent.config.promptProfile().lean() || agent.hasTool("tasks") { + t.Fatal("the fixture did not keep tasks on a shelf") + } + if err := SaveMeta(dir, Meta{ID: "0123456789abcdef", Workspace: agent.config.Workspace}); err != nil { + t.Fatal(err) + } + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + collect(t, mustSubmit(t, agent, "hello")) + if _, failed := agent.loadCapability("tasks"); failed { + t.Fatal("load tasks") + } + agent.armMu.Lock() + description := "" + for _, tool := range agent.tools { + if tool.Name == "tasks" { + description = tool.Description + } + } + agent.armMu.Unlock() + if !strings.Contains(description, "Search lists only this conversation's own work") || strings.Contains(description, "another window") { + t.Fatalf("the loaded tasks tool kept the project's words: %q", description) + } +} + // L-FIX9-cross A1 func TestConversationScopeLeavesOtherWorkOutOfTurnContext(t *testing.T) { agent, dir := scopeRig(t) diff --git a/internal/session/session.go b/internal/session/session.go index 361908025e..6a331f46dd 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2572,6 +2572,11 @@ type Config struct { // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. ClientTools ClientToolDoor + // ownWork says the page is composed for a conversation told only about its + // own work (scope.go). Only the agent sets it, from the scope it last read, + // so the page and the tasks description cannot disagree. + ownWork bool + // 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 @@ -2838,7 +2843,10 @@ type Agent struct { knowledgeVault *knowledge.Vault knowledgeJournal *knowledgeJournal taskComputerPrompt bool - remoteAckJobs sync.WaitGroup + // ownWorkTold is the scope the page and the tasks description last told the + // model (scope.go). It is atomic because belts are built outside a.mu. + ownWorkTold atomic.Bool + remoteAckJobs sync.WaitGroup // knowledgeWake is signalled after each journal line reaches the file. // initKnowledge creates it once before the agent is reachable and never // replaces it, so writers read it without a lock. diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index 34baf90ea5..ae29e4b211 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -69,7 +69,13 @@ import ( // stated once, on the page's own routing table, rather than once per tool here — // eighteen descriptions each carrying their own routing clause is the same table // written eighteen times and billed on every request of every turn. -const tasksDescription = "Find tasks. A finished task is asked about with `tasks` and is never redone or rechecked by hand. No id searches; an id reads, steers, stops, forwards, continues or settles one. To END running work use stop; say may be ignored. Never to WAIT for handed-off work. Search includes other windows' live work as `another window`, with no id here." +const tasksDescription = tasksDescriptionCore + " Search includes other windows' live work as `another window`, with no id here." + +// tasksDescriptionOwnWork is the same contract for a conversation told only +// about its own work (scope.go), whose search reaches no other window. +const tasksDescriptionOwnWork = tasksDescriptionCore + " Search lists only this conversation's own work: other windows and other projects are hidden from it, even with scope everywhere." + +const tasksDescriptionCore = "Find tasks. A finished task is asked about with `tasks` and is never redone or rechecked by hand. No id searches; an id reads, steers, stops, forwards, continues or settles one. To END running work use stop; say may be ignored. Never to WAIT for handed-off work." // The schema's `resolve` enum is INTERPOLATED from [TaskResolutions] rather // than typed out, because the landing note offers the same three words to the @@ -220,9 +226,15 @@ func (a *Agent) tasksTool() bare.Tool { if a.config.oneTaskRoad() { schema = tasksSchemaWithNote } + // AND THE DESCRIPTION SAYS WHOSE WORK A SEARCH REACHES, from the scope the + // page was told ([Agent.followWorkScopeLocked]). + description := tasksDescription + if a.ownWorkTold.Load() { + description = tasksDescriptionOwnWork + } return bare.Tool{ Name: "tasks", - Description: tasksDescription, + Description: description, Schema: json.RawMessage(schema), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var parsed tasksArguments From 3ded075ad54ab67d471897e85c6f4d8c92cf2fd3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 12 Nov 2026 18:39:43 -0500 Subject: [PATCH 8/8] Client tools: a session swap drops the old conversation's sets A holder shares one ClientToolHub across the initial, fresh and resumed openings of a remote Session. When another window ran /new or /resume, Session.swap replaced the agent but left every connection's sets in the hub, so the replacement conversation got a joined app's tools and instructions, and its calls went to that app under the old agent's id. The app then wrote into the old workstream, and its answer was refused with ErrJoinedGone, so the holder's model was told client_timeout. Session.swap now calls ClientToolHub.forget before the new agent is reachable: every connection's sets go, pending calls end with a clientToolEnded frame so the app stops waiting too, and a joined connection's snapshot that was already past the session's check is refused. A following surface may still attach to the open conversation. PROTOCOL.md, REMOTE.md, the client-tools manual page and the change entry say so. Co-Authored-By: Claude Opus 5.5 --- docs/REMOTE.md | 8 +- docs/changes/unreleased/2613-client-tools.md | 4 +- docs/design/app-v3/PROTOCOL.md | 5 +- internal/manual/chat/client-tools.md | 3 +- internal/remote/clienttools.go | 26 +++++++ internal/remote/clienttools_test.go | 78 ++++++++++++++++++++ internal/remote/server.go | 8 ++ 7 files changed, 127 insertions(+), 5 deletions(-) diff --git a/docs/REMOTE.md b/docs/REMOTE.md index c283d68c0a..948c50e099 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -89,8 +89,12 @@ with that connection, and leaving removes its sets and settles its waits. Watching connections cannot attach or answer tools. A roaming client resends its last acknowledged snapshot after redial only onto the same conversation, using the welcome's transcript identity (or live instance when unnamed). A -changed conversation forgets that snapshot. One sender per owner skips queued -calls whose wait ended, so a stuck write blocks only that owner's sender. +changed conversation forgets that snapshot. A session swap (`Session.New` or +`Session.Open`) drops every connection's sets and ends pending calls with +`clientToolEnded`; a joined connection's later snapshot is refused, while a +following surface may attach to the conversation now open. One sender per owner +skips queued calls whose wait ended, so a stuck write blocks only that owner's +sender. Client requests share the ordinary call/write path and its failure behavior. A holder without the flag receives none of these new calls. diff --git a/docs/changes/unreleased/2613-client-tools.md b/docs/changes/unreleased/2613-client-tools.md index cb0d7fba14..f0d086b897 100644 --- a/docs/changes/unreleased/2613-client-tools.md +++ b/docs/changes/unreleased/2613-client-tools.md @@ -32,7 +32,9 @@ replayed call; turns the engine starts itself carry none. The hello feature codeaf host already holds, `agents.tools_set` sends the agent's sets to that holder. Its calls come back on the app connection that set them, and answers go back the same way. A connection that leaves loses its sets, and its pending -calls end at once. Two connections on one conversation keep separate sets. A +calls end at once. When another window starts a new conversation or opens +another one in that holder, every connection's sets are dropped and their calls +end, so the replacement conversation never gets them. Two connections on one conversation keep separate sets. A chat held on another computer keeps its sets until it runs here again. The app hello adds `agents.client_tools.remote`. A holder from an older build is answered with `bad_scope`, and the remote protocol version is unchanged. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 5f9e48e00e..8229ede654 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -120,7 +120,10 @@ Children, workers and clarification conversations inherit neither tools nor instructions. Within the same app connection, every join or re-join pushes the conversation's current sets to its holder; a roaming link resends them after redial only when the welcome identifies the same conversation. A changed -conversation forgets the old snapshot and ends its calls. A returning owner's +conversation forgets the old snapshot and ends its calls. When another window +starts a new conversation or opens another one in the holder, every +connection's sets for the old conversation are dropped and its calls end, so +none reach the replacement. A returning owner's random token moves its sets to the replacement connection even while the old connection remains attached; other owners still face collision checks. A conversation returning to this engine uses the registry directly. diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md index 163fadb4fa..eb3113c8d6 100644 --- a/internal/manual/chat/client-tools.md +++ b/internal/manual/chat/client-tools.md @@ -30,7 +30,8 @@ Results are compact JSON, cut at 64 KiB with the dropped-byte count. Error answers take 1–4096 bytes. Joined holders forward tools when supported; older holders refuse attachment. -Reconnect restores sets only to the same conversation. A chat held on another +Reconnect restores sets only to the same conversation. A holder switching to +another chat drops the tools and ends their calls. A chat held on another computer stores sets until "Continue here" or take. Each connection owns its tools and calls. Link loss ends calls when observed or at their wait limit; later answers are refused. diff --git a/internal/remote/clienttools.go b/internal/remote/clienttools.go index e57f5ce032..20d2c47d79 100644 --- a/internal/remote/clienttools.go +++ b/internal/remote/clienttools.go @@ -91,6 +91,11 @@ type ClientToolHub struct { sets []session.ClientToolSet pending map[string]*clientToolWait sequence uint64 + // swapped and conversation record the conversation a swap moved to + // ([ClientToolHub.forget]), so a joined connection's late snapshot for the + // one it left is refused. + swapped bool + conversation string } var _ session.ClientToolDoor = (*ClientToolHub)(nil) @@ -133,6 +138,9 @@ func (h *ClientToolHub) replace(owner *server, token string, sets []session.Clie if owner.clientToolsGone.Load() { return nil, ErrClientToolDisconnected } + if h.swapped && owner.joined != "" && !sameTranscript(owner.joined, h.conversation) { + return nil, ErrJoinedGone + } if len(token) < clientToolOwnerMin || len(token) > clientToolOwnerMax { return nil, fmt.Errorf("client tools need an owner token of %d–%d bytes", clientToolOwnerMin, clientToolOwnerMax) } @@ -245,6 +253,24 @@ func (h *ClientToolHub) settleLocked(id string, pending *clientToolWait, answer return true } +// forget ends the hub's tie to the conversation a session is leaving. The hub +// outlives [Session.swap], but sets belong to one conversation: every +// connection's sets go, so none reach the replacement, and each pending call +// ends with an ended frame, so the app stops waiting too. A joined connection +// cannot attach again; any other connection may attach to the new one. +func (h *ClientToolHub) forget(file string) { + h.mu.Lock() + defer h.mu.Unlock() + h.swapped, h.conversation = true, file + for _, state := range h.owners { + state.sets = nil + } + for id, pending := range h.pending { + h.settleLocked(id, pending, clientToolWaitEnded(pending.call, false), false) + } + h.snapshotLocked() +} + func (h *ClientToolHub) drop(owner *server) { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/remote/clienttools_test.go b/internal/remote/clienttools_test.go index 7473647d4e..19fc43cda2 100644 --- a/internal/remote/clienttools_test.go +++ b/internal/remote/clienttools_test.go @@ -670,6 +670,84 @@ func TestClientToolsRemoteModelBoundary(t *testing.T) { } } +// C3: sets belong to one conversation. When another window swaps the session +// to a new or another conversation, the app that joined the old one must not +// lend its tools to the replacement, and a pending call ends at once. +func TestClientToolsRemoteSessionSwapDropsTheOldConversationsSets(t *testing.T) { + for _, door := range []string{"new", "open"} { + t.Run(door, func(t *testing.T) { + hub := &ClientToolHub{} + engine := &Engine{Agent: &fakeAgent{}, ClientTools: hub, SessionFile: "/a.jsonl", + Fresh: func() (WrappedAgent, string, error) { return &fakeAgent{}, "/b.jsonl", nil }, + Open: func(string) (WrappedAgent, bool, error) { return &fakeAgent{}, true, nil }} + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + app, err := LoopbackAttach(Hello{Session: "/a.jsonl", Join: true}, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = app.Close() }) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := app.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + entered, ended := make(chan struct{}, 4), make(chan struct{}, 4) + app.Client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + entered <- struct{}{} + <-ctx.Done() + ended <- struct{}{} + return session.ClientToolAnswer{}, ctx.Err() + }) + pending := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + window := clientToolTestPair(t, held) + if door == "new" { + _, err = window.Client.NewSession() + } else { + _, err = window.Client.OpenSession("/c.jsonl") + } + if err != nil { + t.Fatal(err) + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatalf("the replacement conversation got the joined app's sets: %+v", sets) + } + got := clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_note")) + if !got.IsError || got.Text != session.ClientToolUnavailable("app_note").Text { + t.Fatalf("the replacement conversation reached the joined app: %+v", got) + } + if got := clientToolTestAnswer(t, pending); !got.IsError || !strings.HasPrefix(got.Text, "the turn ended before the app answered app_note") { + t.Fatalf("the old conversation's pending call: %+v", got) + } + clientToolTestSignal(t, ended) + select { + case <-entered: + t.Fatal("the joined app received a call from the replacement conversation") + default: + } + if _, err := app.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err == nil { + t.Fatal("the joined app attached its tools to the replacement conversation") + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatalf("a refused snapshot reached the replacement conversation: %+v", sets) + } + // A snapshot already past the session's check when the swap landed. + late := &server{joined: "/a.jsonl"} + if _, err := hub.replace(late, strings.Repeat("t", clientToolOwnerMin), []session.ClientToolSet{set}); !errors.Is(err, ErrJoinedGone) { + t.Fatalf("a late snapshot for the old conversation: %v", err) + } + // A window that follows the session may attach to what is open now. + if _, err := window.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatalf("a following window could not attach to the open conversation: %v", err) + } + if _, sets := hub.ClientToolSets(); len(sets) != 1 { + t.Fatalf("the following window's sets: %+v", sets) + } + }) + } +} + func clientToolTestAnswer(t *testing.T, answer <-chan session.ClientToolAnswer) session.ClientToolAnswer { t.Helper() select { diff --git a/internal/remote/server.go b/internal/remote/server.go index 6cecbc1980..635ab163f1 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1802,6 +1802,14 @@ func (sess *Session) swap(asked *server, build func() (WrappedAgent, string, boo sess.follows = map[uint64]followQueued{} sess.held.forget() sess.engine.SessionFile = file + // AND THE APP TOOLS OF THE CONVERSATION LEFT BEHIND STAY WITH IT. The hub + // is shared across openings, so without this an app that joined the old + // conversation would lend its tools, and its calls, to the new one. It is + // done before the new agent is reachable, so its first step already sees + // the empty snapshot. + if sess.engine.ClientTools != nil { + sess.engine.ClientTools.forget(file) + } sess.engine.Resumed = resumed // The note belonged to the launch and to nothing after it: a swap the // person asked for is not a session that moved under them.