diff --git a/agent/harness/todo/todo.go b/agent/harness/todo/todo.go index 6b85ca96..c7236256 100644 --- a/agent/harness/todo/todo.go +++ b/agent/harness/todo/todo.go @@ -14,7 +14,6 @@ import ( "context" "fmt" "runtime" - "slices" "strings" "sync" "weak" @@ -133,27 +132,31 @@ func (p *Provider) Invoked(ctx context.Context, invoked agent.InvokedContext) er // GetAllItems returns all todo items from the session state. func (p *Provider) GetAllItems(opts ...agent.Option) []Item { - mu := p.getSessionLock(opts) - mu.Lock() - defer mu.Unlock() - st := p.loadState(opts) - return slices.Clone(st.Items) + return p.GetAllTodos(sessionFromOptions(opts)) } // GetRemainingItems returns only the incomplete todo items from the session state. func (p *Provider) GetRemainingItems(opts ...agent.Option) []Item { - mu := p.getSessionLock(opts) + return p.GetRemainingTodos(sessionFromOptions(opts)) +} + +// GetAllTodos returns all todo items stored in session. +func (p *Provider) GetAllTodos(session *agent.Session) []Item { + mu := p.getSessionLock(session) mu.Lock() defer mu.Unlock() - st := p.loadState(opts) - return remainingItems(st.Items) + return copyItems(p.loadState(session).Items) } -func (p *Provider) loadState(opts []agent.Option) *state { - session, ok := agent.GetOption(opts, agent.WithSession) - if !ok { - return &state{} - } +// GetRemainingTodos returns only the incomplete todo items stored in session. +func (p *Provider) GetRemainingTodos(session *agent.Session) []Item { + mu := p.getSessionLock(session) + mu.Lock() + defer mu.Unlock() + return remainingItems(p.loadState(session).Items) +} + +func (p *Provider) loadState(session *agent.Session) *state { var s state if found, _ := session.Get(stateKey, &s); found { return &s @@ -161,9 +164,8 @@ func (p *Provider) loadState(opts []agent.Option) *state { return &state{} } -func (p *Provider) saveState(opts []agent.Option, s *state) { - session, ok := agent.GetOption(opts, agent.WithSession) - if !ok || s == nil { +func (p *Provider) saveState(session *agent.Session, s *state) { + if session == nil || s == nil { return } session.Set(stateKey, *s) @@ -185,9 +187,8 @@ func (p *Provider) saveState(opts []agent.Option, s *state) { // Weak keys do not keep sessions alive and do not remove map entries on their // own, so a runtime cleanup deletes the entry once the session is collected, // keeping the registry from growing unbounded. -func (p *Provider) getSessionLock(opts []agent.Option) *sync.Mutex { - session, ok := agent.GetOption(opts, agent.WithSession) - if !ok || session == nil { +func (p *Provider) getSessionLock(session *agent.Session) *sync.Mutex { + if session == nil { return &p.nullSessionLock } key := weak.Make(session) @@ -208,6 +209,7 @@ func (p *Provider) getSessionLock(opts []agent.Option) *sync.Mutex { func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { opts := invoking.Options + session := sessionFromOptions(opts) tools := p.createTools(opts) var outOpts []agent.Option @@ -222,9 +224,9 @@ func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) // Inject current todo list summary so the agent sees outstanding work. if !p.suppressTodoMessage { - mu := p.getSessionLock(opts) + mu := p.getSessionLock(session) mu.Lock() - st := p.loadState(opts) + st := p.loadState(session) mu.Unlock() var todoMsg string @@ -240,16 +242,17 @@ func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) } func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { + session := sessionFromOptions(opts) addTool := functool.MustNew( functool.Config{ Name: "todos_add", Description: "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.", }, func(ctx context.Context, input []ItemInput) ([]Item, error) { - mu := p.getSessionLock(opts) + mu := p.getSessionLock(session) mu.Lock() defer mu.Unlock() - st := p.loadState(opts) + st := p.loadState(session) var created []Item for _, in := range input { item := Item{ @@ -263,7 +266,7 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { st.Items = append(st.Items, item) created = append(created, item) } - p.saveState(opts, st) + p.saveState(session, st) return created, nil }, ) @@ -274,10 +277,10 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { Description: "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.", }, func(ctx context.Context, items []CompleteInput) (int, error) { - mu := p.getSessionLock(opts) + mu := p.getSessionLock(session) mu.Lock() defer mu.Unlock() - st := p.loadState(opts) + st := p.loadState(session) idSet := make(map[int]struct{}, len(items)) for _, item := range items { idSet[item.ID] = struct{}{} @@ -290,7 +293,7 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { } } if completed > 0 { - p.saveState(opts, st) + p.saveState(session, st) } return completed, nil }, @@ -302,10 +305,10 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { Description: "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.", }, func(ctx context.Context, ids []int) (int, error) { - mu := p.getSessionLock(opts) + mu := p.getSessionLock(session) mu.Lock() defer mu.Unlock() - st := p.loadState(opts) + st := p.loadState(session) idSet := make(map[int]struct{}, len(ids)) for _, id := range ids { idSet[id] = struct{}{} @@ -321,7 +324,7 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { } if removed > 0 { st.Items = remaining - p.saveState(opts, st) + p.saveState(session, st) } return removed, nil }, @@ -333,11 +336,7 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { Description: "Retrieve the list of incomplete todo items.", }, func(ctx context.Context, _ struct{}) ([]Item, error) { - mu := p.getSessionLock(opts) - mu.Lock() - defer mu.Unlock() - st := p.loadState(opts) - return remainingItems(st.Items), nil + return p.GetRemainingTodos(session), nil }, ) @@ -347,11 +346,7 @@ func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { Description: "Retrieve the full list of todo items, both complete and incomplete.", }, func(ctx context.Context, _ struct{}) ([]Item, error) { - mu := p.getSessionLock(opts) - mu.Lock() - defer mu.Unlock() - st := p.loadState(opts) - return st.Items, nil + return p.GetAllTodos(session), nil }, ) @@ -368,6 +363,17 @@ func remainingItems(items []Item) []Item { return remaining } +func copyItems(items []Item) []Item { + result := make([]Item, len(items)) + copy(result, items) + return result +} + +func sessionFromOptions(opts []agent.Option) *agent.Session { + session, _ := agent.GetOption(opts, agent.WithSession) + return session +} + func formatTodoListMessage(items []Item) string { if len(items) == 0 { return "### Current todo list\n- none yet" diff --git a/agent/harness/todo/todo_test.go b/agent/harness/todo/todo_test.go index c81887bc..88beaf08 100644 --- a/agent/harness/todo/todo_test.go +++ b/agent/harness/todo/todo_test.go @@ -372,6 +372,24 @@ func TestPublicGetAllTodos_ReturnsAllItems(t *testing.T) { } } +func TestPublicGetAllTodosFromSession_ReturnsAllItems(t *testing.T) { + p := todo.New(nil) + session := agenttest.CreateSession() + opts := []agent.Option{agent.WithSession(session)} + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + callTool(t, outOpts, "todos_add", `{"Arg0":[{"title":"X"},{"title":"Y"}]}`) + + all := p.GetAllTodos(session) + if len(all) != 2 { + t.Fatalf("expected 2 items, got %d", len(all)) + } +} + // 14. PublicGetRemainingTodos_ReturnsOnlyIncomplete func TestPublicGetRemainingTodos_ReturnsOnlyIncomplete(t *testing.T) { p := todo.New(nil) @@ -395,6 +413,29 @@ func TestPublicGetRemainingTodos_ReturnsOnlyIncomplete(t *testing.T) { } } +func TestPublicGetRemainingTodosFromSession_ReturnsOnlyIncomplete(t *testing.T) { + p := todo.New(nil) + session := agenttest.CreateSession() + opts := []agent.Option{agent.WithSession(session)} + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + callTool(t, outOpts, "todos_add", `{"Arg0":[{"title":"Done"},{"title":"Open"}]}`) + items := p.GetAllTodos(session) + callTool(t, outOpts, "todos_complete", fmt.Sprintf(`{"Arg0":[{"id":%d,"reason":"done"}]}`, items[0].ID)) + + remaining := p.GetRemainingTodos(session) + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining, got %d", len(remaining)) + } + if remaining[0].Title != "Open" { + t.Errorf("expected 'Open', got %q", remaining[0].Title) + } +} + // 15. PublicGetAllTodos_ReturnsEmptyForNewSession func TestPublicGetAllTodos_ReturnsEmptyForNewSession(t *testing.T) { p := todo.New(nil) @@ -406,6 +447,15 @@ func TestPublicGetAllTodos_ReturnsEmptyForNewSession(t *testing.T) { } } +func TestPublicGetAllTodosFromNilSession_ReturnsEmpty(t *testing.T) { + p := todo.New(nil) + + items := p.GetAllTodos(nil) + if len(items) != 0 { + t.Fatalf("expected 0 items for nil session, got %d", len(items)) + } +} + // 16. Options_CustomInstructions_OverridesDefault func TestCustomInstructions_OverridesDefault(t *testing.T) { p := todo.New(&todo.Options{ @@ -692,7 +742,8 @@ func TestCompleteTodos_EmptyReasonIsAccepted(t *testing.T) { // state. Run under -race. func TestTodo_ConcurrentSessionAccess_NoDataRace(t *testing.T) { p := todo.New(nil) - opts := sessionOpts() + session := agenttest.CreateSession() + opts := []agent.Option{agent.WithSession(session)} _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) if err != nil { @@ -721,6 +772,8 @@ func TestTodo_ConcurrentSessionAccess_NoDataRace(t *testing.T) { defer wg.Done() _ = p.GetAllItems(opts...) _ = p.GetRemainingItems(opts...) + _ = p.GetAllTodos(session) + _ = p.GetRemainingTodos(session) }(i*2 + 1) } wg.Wait() diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..a45fc381 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -76,7 +76,7 @@ Intentional contract choices in this parity pass: | Logging | Microsoft.Extensions.Logging source-generated logs. | `slog` logger support through `agent.Config.Logger`, automatic agent run logs, and provider/middleware diagnostics. | Partial | Logging ecosystems differ. | | OpenTelemetry for agents | Agent/workflow observability samples and OpenTelemetry workflow builder extension. | `provider/otelprovider`, `workflow/observability/opentelemetry`, workflow builder instrumentation via `WithTelemetry`, trace context propagation in workflow context. | Aligned | API shape differs: Go passes a tracer from the OpenTelemetry adapter separately from `TelemetryOptions` and keeps workflow observability internals unexported. | | Evaluation | Agent evaluation extensions, eval checks, local/function evaluators, conversation splitters, workflow evaluation samples, Foundry quality samples. | No evaluation package. | .NET only | No Go equivalent found. | -| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`; message injection is supplied through `agent.Config`. | Partial | Go now has packaged harness support for agent mode, todo tracking, tool approval, tool auto-call, message injection, and loop reinvocation with delegate/completion-marker evaluators. It still lacks file access, file memory, file store, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071). | +| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`; message injection is supplied through `agent.Config`. | Partial | Go now has packaged harness support for agent mode, todo tracking, tool approval, tool auto-call, message injection, and loop reinvocation with delegate/completion-marker evaluators. It still lacks file access, file memory, file store, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071), and the todo provider now exposes matching session-first todo read helpers (#7052). | | RAG | Basic text RAG, custom vector store RAG, custom data source RAG, Foundry service RAG, Neo4j graph RAG samples. | No RAG package or sample found. | .NET only | Go has data/file/vector content types but no RAG workflow package or samples. | | Purview | `Microsoft.Agents.AI.Purview` models and end-to-end sample. | No equivalent package. | .NET only | No Go governance/Purview integration. | | Cosmos DB storage | Cosmos chat history provider and workflow checkpoint store. | No built-in Cosmos package. | .NET only | Go has public in-memory and JSON/file workflow checkpoint stores plus a custom store interface, but no Cosmos DB provider. |