Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions cmd/codeaf/appscope.go
Original file line number Diff line number Diff line change
@@ -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
}
154 changes: 154 additions & 0 deletions cmd/codeaf/appscope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
18 changes: 18 additions & 0 deletions docs/changes/unreleased/2462-agent-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
kind: added
title: A conversation can be told only about its own work (agents.set_scope)
pr: 2462
surface: [app, engine]
invalidates:
- "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and restores the earlier behavior."
---

`agents.set_scope {id, scope}` stores `project` or `conversation` and answers the
stored word. Engines serving it announce `agents.scope`. The choice survives a
restart and takes effect on the next turn or tasks call without opening,
warming or interrupting the conversation. Its folder, transcript, title, model
and tasks stay as they were. A lent conversation or one without a local folder
is refused.

The person's task list, task nodes and the write-safety checks keep their
existing behavior.
24 changes: 24 additions & 0 deletions docs/design/app-v3/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,35 @@ decides which combinations are accepted.
| `agents.set_effort` | `{effort, id}` | `agentResult` |
| `agents.set_model` | `{automatic, id, model}` | `agentResult` |
| `agents.set_models` | `{fallback, id, main}` | `agentResult` |
| `agents.set_scope` | `{id, scope}` | `AgentScope` |
| `agents.since` | `{after, id}` | `Since` |
| `agents.steer` | `in.sayTo` | `Empty` |
| `agents.stop` | `in.IDParams` | `Empty` |
| `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` |

## Conversation work scope

`agents.set_scope {id, scope}` answers `{id, scope}` with the stored word.
`project` is the default: the conversation is told about other conversations'
landed and running work in its folder, as today. `conversation` means it is told
only about its own work: its turns receive no other-conversation `<elsewhere>`
block, and its `tasks` tool lists and addresses only its own tasks. Neither the
other-window running section nor the wider `scope: "everywhere"` section is
added. Task nodes keep their existing scope.

The choice is stored with the conversation and survives restarts. It takes
effect from the conversation's next turn; a live tasks call also reads the
stored choice. Setting it never opens, warms or interrupts the conversation,
never moves its folder, and never changes its transcript, title, model or tasks.
The write-safety checks still warn before its own work writes over files other
conversations are writing. The person's task list is unchanged.

Engines that serve this method announce `agents.scope` in `hello.features`.
A missing id or a scope other than `project` or `conversation` is `bad_params`;
an engine without the door answers `unknown_method`. An unknown conversation is
`not_found`; a lent conversation or one without a local folder is `conflict`.
No `agent.changed` event is emitted, because the Agent view carries no scope.

## Client tools

The optional features `agents.client_tools` and `agents.client_tools.source`
Expand Down
43 changes: 43 additions & 0 deletions internal/appwire/agent_scope.go
Original file line number Diff line number Diff line change
@@ -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
})
}
92 changes: 92 additions & 0 deletions internal/appwire/agent_scope_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
1 change: 1 addition & 0 deletions internal/appwire/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En
hello = withCrewsOrganizeFeature(hello, engine)
hello = withReplySuggestionFeature(hello, engine)
hello = withUnreadFeature(hello, engine)
hello = withAgentScopeFeature(hello, engine)
hello = withErrandFeature(hello, engine)
hello = withClientToolsFeature(hello, engine)
hello = withAsksDrawnFeature(hello, engine)
Expand Down
Loading
Loading