mirror of
https://github.com/mudler/cogito.git
synced 2026-07-23 18:35:23 -04:00
67811faaf5
* feat(agent): add AgentID to SessionState for sub-agent tool routing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent): route sub-agent tool calls through parent callback + MCPs with AgentID Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent): add AgentDefinition type and WithAgentDefinitions option * feat(agent): apply agent definition (prompt/tools/limits) on spawn * feat(clients): add temperature support to OpenAI client * feat(agent): per-agent model+temperature via WithAgentLLMFactory * feat(agent): per-agent injection channel and AgentManager.Inject * feat(agent): add unified send_agent_message resume/inject tool * feat(agent): detachable foreground spawns + AgentManager.Detach Register every foreground sub-agent and run it in a goroutine so an embedder can promote it to the background. spawn_agent now selects on agent.done (completed -> return result like the old synchronous path), agent.detach (promoted -> return the ID immediately, goroutine keeps running), or ctx.Done. Extract the shared goroutine body into runAgent and add derefFragment so foreground and background share lifecycle bookkeeping. Stamp withAgentIDStamp(agentID) with the real registry ID on BOTH foreground and background registered agents so sub-agent tool calls carry the correct AgentID. Add AgentState.detach and AgentManager.Detach (errors on unknown / non-detachable agent; non-blocking send). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent): add WithAgentSpawnCallback and AgentState.Type for running-agent signal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(agent): cover definitions, AgentID approval, and spawn callback in Sub-Agent Spawning suite Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
3.5 KiB
Go
122 lines
3.5 KiB
Go
package cogito
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// TestWithAgentSpawnCallbackStores asserts the option stores the fn on Options.
|
|
func TestWithAgentSpawnCallbackStores(t *testing.T) {
|
|
o := defaultOptions()
|
|
o.Apply(WithAgentSpawnCallback(func(*AgentState) {}))
|
|
if o.agentSpawnCallback == nil {
|
|
t.Fatal("spawn callback not stored")
|
|
}
|
|
}
|
|
|
|
// TestSpawnCallbackFiresForeground asserts a foreground spawn fires the spawn
|
|
// callback with a running AgentState whose Type matches the requested type.
|
|
func TestSpawnCallbackFiresForeground(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var fired bool
|
|
var gotStatus AgentStatusType
|
|
var gotType string
|
|
var nonNil bool
|
|
|
|
defs := []AgentDefinition{{Name: "explore", SystemPrompt: "You are EXPLORE."}}
|
|
runner := &spawnAgentRunner{
|
|
llm: newReplyLLM("foreground done"),
|
|
manager: NewAgentManager(),
|
|
ctx: context.Background(),
|
|
agentDefinitions: defs,
|
|
// Snapshot the AgentState fields at callback time. The foreground agent
|
|
// runs in a goroutine and may mutate Status to "completed" by the time
|
|
// Run returns, so we must capture the values inside the callback (while
|
|
// Status is still running) rather than reading the live pointer after.
|
|
agentSpawnCallback: func(a *AgentState) {
|
|
mu.Lock()
|
|
fired = true
|
|
nonNil = a != nil
|
|
if a != nil {
|
|
gotStatus = a.Status
|
|
gotType = a.Type
|
|
}
|
|
mu.Unlock()
|
|
},
|
|
}
|
|
|
|
_, _, err := runner.Run(SpawnAgentArgs{AgentType: "explore", Task: "look around", Background: false})
|
|
if err != nil {
|
|
t.Fatalf("foreground spawn errored: %v", err)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if !fired {
|
|
t.Fatal("spawn callback did not fire for foreground spawn")
|
|
}
|
|
if !nonNil {
|
|
t.Fatal("spawn callback received a nil AgentState")
|
|
}
|
|
if gotStatus != AgentStatusRunning {
|
|
t.Fatalf("spawn callback AgentState status = %q, want %q", gotStatus, AgentStatusRunning)
|
|
}
|
|
if gotType != "explore" {
|
|
t.Fatalf("spawn callback AgentState type = %q, want %q", gotType, "explore")
|
|
}
|
|
}
|
|
|
|
// TestSpawnCallbackFiresBackground asserts a background spawn fires the spawn
|
|
// callback synchronously (before Run returns the ID) with a running AgentState
|
|
// whose Type matches the requested type.
|
|
func TestSpawnCallbackFiresBackground(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var fired bool
|
|
var gotStatus AgentStatusType
|
|
var gotType string
|
|
var nonNil bool
|
|
|
|
defs := []AgentDefinition{{Name: "plan", SystemPrompt: "You are PLAN."}}
|
|
runner := &spawnAgentRunner{
|
|
llm: newReplyLLM("background done"),
|
|
manager: NewAgentManager(),
|
|
ctx: context.Background(),
|
|
agentDefinitions: defs,
|
|
// The background spawn fires the callback synchronously (before Run
|
|
// returns the ID) while Status is still running, but the agent's
|
|
// goroutine may mutate Status afterward, so snapshot inside the callback.
|
|
agentSpawnCallback: func(a *AgentState) {
|
|
mu.Lock()
|
|
fired = true
|
|
nonNil = a != nil
|
|
if a != nil {
|
|
gotStatus = a.Status
|
|
gotType = a.Type
|
|
}
|
|
mu.Unlock()
|
|
},
|
|
}
|
|
|
|
out, _, err := runner.Run(SpawnAgentArgs{AgentType: "plan", Task: "make a plan", Background: true})
|
|
if err != nil {
|
|
t.Fatalf("background spawn errored: %v", err)
|
|
}
|
|
_ = out
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if !fired {
|
|
t.Fatal("spawn callback did not fire for background spawn")
|
|
}
|
|
if !nonNil {
|
|
t.Fatal("spawn callback received a nil AgentState")
|
|
}
|
|
if gotStatus != AgentStatusRunning {
|
|
t.Fatalf("spawn callback AgentState status = %q, want %q", gotStatus, AgentStatusRunning)
|
|
}
|
|
if gotType != "plan" {
|
|
t.Fatalf("spawn callback AgentState type = %q, want %q", gotType, "plan")
|
|
}
|
|
}
|