diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 910808abb0..22263822d9 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 @@ -2052,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 }) @@ -4671,6 +4674,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..6145eb44d8 --- /dev/null +++ b/cmd/codeaf/appclienttools.go @@ -0,0 +1,525 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "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/session" +) + +const clientToolSettledTTL = 10 * time.Minute +const clientToolSettledMax = 1024 + +var appClientToolsBackend atomic.Pointer[appBackend] + +type clientToolAgentSets struct { + version uint64 + sets []session.ClientToolSet + builtins map[string]bool +} + +type clientToolPending struct { + ctx context.Context + done chan struct{} + call session.ClientToolCall + answerBy 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{} + publishing map[string]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 + } + if agent := b.clientToolRemoteAgent(a); agent != nil { + return b.setRemoteClientTools(a, agent, p) + } + 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 { + if agent := b.clientToolRemoteAgent(a); agent != nil && !agent.SupportsClientTools() { + 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 +} + +// 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) { + if err := ctx.Err(); err != nil { + return clientToolContextEnded(ctx, 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) + 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, 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 +} + +// 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.answerBy) { + 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 + } + 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 { + waitMS = call.DeadlineMS + } + 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) + } + 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.answerBy)) + 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 = clientToolContextEnded(ctx, 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, clientToolContextEnded(pending.ctx, pending.call.Name)) + return appwire.ClientToolResultAck{}, nil + } + if !time.Now().Before(pending.answerBy) { + 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_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..947abe37e3 --- /dev/null +++ b/cmd/codeaf/appclienttools_remote_test.go @@ -0,0 +1,617 @@ +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) + 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 + 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):]) + } +} + +// 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, "") + 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/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go new file mode 100644 index 0000000000..9dcc709cfe --- /dev/null +++ b/cmd/codeaf/appclienttools_test.go @@ -0,0 +1,1031 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "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 { + 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) { + 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() + 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) + } +} + +func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + 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") + 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), 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) || event.SourceMessageID != call.SourceMessage { + 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)) + } + // 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 }) + 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), (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) + 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) + 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.answerBy = 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), 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] + 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") + } + 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) + } +} + +// 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/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/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..85a113b631 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, 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 @@ -1261,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..948c50e099 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -76,6 +76,28 @@ 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. 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. + **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/2613-client-tools.md b/docs/changes/unreleased/2613-client-tools.md new file mode 100644 index 0000000000..f0d086b897 --- /dev/null +++ b/docs/changes/unreleased/2613-client-tools.md @@ -0,0 +1,53 @@ +--- +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. 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`. + +`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. 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. + +**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. 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. + +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. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index 897d253df7..8229ede654 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -75,11 +75,157 @@ 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. 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, 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. + +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` +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. `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. 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. 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. +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 | +|---|---|---| +| `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 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. + +| event | data | +|---|---| +| `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 +process-local conversation counter, which resets after restart; +`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 +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. + +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.` +- 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/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/client_tools.go b/internal/appwire/client_tools.go new file mode 100644 index 0000000000..e3637496df --- /dev/null +++ b/internal/appwire/client_tools.go @@ -0,0 +1,166 @@ +package appwire + +import ( + "encoding/json" + "regexp" + + "github.com/Agent-Field/codeaf/internal/session" +) + +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. +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"` + 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 { + 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 { + hello = withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) + if remote, ok := engine.(interface{ ClientToolsRemote() bool }); ok && remote.ClientToolsRemote() { + hello = withFeature(hello, FeatureClientToolsRemote) + } + } + 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..70f2b779b8 --- /dev/null +++ b/internal/appwire/client_tools_test.go @@ -0,0 +1,209 @@ +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) and source contract (d): both features accompany the 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, source := false, false + for _, feature := range frame.Data.Features { + found = found || feature == FeatureClientTools + source = source || feature == FeatureClientToolsSource + } + 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{} + 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/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/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..ae65f03b69 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -215,7 +215,9 @@ 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) hello = withTaskModelsAuxiliaryFeature(hello, engine) hello = withMemorySettingsFeature(hello, engine) @@ -417,7 +419,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..eb3113c8d6 --- /dev/null +++ b/internal/manual/chat/client-tools.md @@ -0,0 +1,37 @@ +# Client tools: tools an app adds to a conversation + +## Client tools: connection, remote holders, limits and waiting + +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. + +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 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 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 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 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/manual/chat/tasks.md b/internal/manual/chat/tasks.md index fcdba1700d..5e99407d70 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,32 @@ 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. 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, +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/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..20d2c47d79 --- /dev/null +++ b/internal/remote/clienttools.go @@ -0,0 +1,680 @@ +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 + // 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) + +// 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 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) + } + 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 +} + +// 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() + 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..19fc43cda2 --- /dev/null +++ b/internal/remote/clienttools_test.go @@ -0,0 +1,1014 @@ +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") + } +} + +// 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 { + 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..635ab163f1 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), @@ -1798,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. @@ -1845,6 +1857,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 +2002,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 +2387,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 +3132,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 +3898,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/agent.go b/internal/session/agent.go index 24bd5fe384..6ed7fcc9ab 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 { @@ -158,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 @@ -295,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 { @@ -1436,6 +1442,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 @@ -2022,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). @@ -2056,7 +2068,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/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..134512985a --- /dev/null +++ b/internal/session/client_tools.go @@ -0,0 +1,374 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "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 `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 { + Set, Name, TurnCall string + SourceMessage 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, + SourceMessage: turnOriginFrom(ctx).answers, + }) + 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..9f5f1e1d76 --- /dev/null +++ b/internal/session/client_tools_test.go @@ -0,0 +1,677 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "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 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) + 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) + } +} + +// 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() + 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), 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." + 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") + } + 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{ + 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() + }) + 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") + } + 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) + } + waitSignal(t, started, "the clarification provider step") + 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/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/prompt.go b/internal/session/prompt.go index fa0e596acd..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. @@ -405,6 +408,7 @@ func renderSystemAt(config Config, now time.Time) string { } out.WriteString(workerFooter(config, now)) + out.WriteString(clientToolsSection(config)) return out.String() } @@ -612,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/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/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/scope.go b/internal/session/scope.go new file mode 100644 index 0000000000..a77d24906a --- /dev/null +++ b/internal/session/scope.go @@ -0,0 +1,148 @@ +package session + +import ( + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +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 +} + +// 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 new file mode 100644 index 0000000000..25e34e9bce --- /dev/null +++ b/internal/session/scope_test.go @@ -0,0 +1,645 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// 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 +} + +// 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) + 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/session.go b/internal/session/session.go index 5b621372ec..6a331f46dd 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,14 @@ 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 + + // 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 @@ -2833,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. @@ -2964,21 +2977,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/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/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 diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index fb0820c213..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 @@ -375,7 +387,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 +1091,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 +1100,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 +1143,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 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)