diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4189a85a..df13826fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ bounded task from the complete board; nonblocking local improvements stay queued Authentication, tenant/credential isolation, state consistency and data loss remain material acceptance requirements. Optional feature equality is not required. After the three profiles pass merged-main validation, publish the results, limitations -and backlog, then stop development until new user direction. Additional harness -implementations and protocol Subagent execution remain queued without changing the -complete pinned protocol target. +and backlog, then stop development until new user direction. Additional harness implementations remain queued. The separately authorized +Subagent batch targets the six read operations across these three harnesses and +does not change the complete pinned protocol target. The current hosted architecture is V1: Core runs independently; each Environment sandbox contains its daemon, selected native harness, local tools and workspace. @@ -923,8 +923,11 @@ publication permanently transfers resource tracking to the Run; subsequent relea does not restore preparation ownership or make its old handle cancel that Run. Forwarded permission and user-choice observations from that cancelled handoff do not register actionable interactions. Codex prepared cancellation also waits for -the transferred Session's local cleanup, which can finish after output closes; -ordinary Session cancellation retains its existing behavior. +the transferred Session's local cleanup, which can finish after output closes. +Codex cancellation has one continuing native owner: caller deadlines bound only +their wait, leaving child observation and cleanup alive for an explicit retry. +Only observed child terminal facts can settle the child; actual observation +failures remain failures. Native process-exit confirmation remains retryable. One prepared-output consumer starts before `Prepared.Start`, so native output beyond the 64-frame channel capacity cannot deadlock Start. It retains the first terminal frame, drains later output, and forwards accepted frames before the observed cancellation @@ -1521,29 +1524,25 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti `disable_subagents` policy on both new and resumed Turns. Native translation stays in the adapter: Codex disables both multi-agent feature generations, overriding operator feature preferences. Product prompts that omit the policy - retain their defaults. Enabled multi-agent execution and public Subagent - resources remain separate implementation gaps; the Agent tools list is not + retain their defaults. Enabled multi-agent observations require separate operation qualification; the Agent tools list is not proven to enumerate every harness-internal utility. -- Private `observe_subagent_identities` requests discover direct root children - from completed native spawn/resume Items. The Codex adapter verifies exact child - identity, persisted parent and original spawn-source parent against its RPC-bound - root. Use parent-filtered persisted `thread/list`; `thread/read` can synthesize - creation time before persistence. Native fields stay in the adapter. One worker - allows 64 candidates and 64 metadata RPCs per dispatch, four 100-row pages per - lookup pass, and three seconds per lookup. Root terminal content and Usage freeze - before a separate, three-second settlement wait; keep the reader free for RPC - replies and deliver successful observations before Done. Owner cancellation, - missing persistence, overflow, failed spawn and late discovery remain explicit - gaps, never invented identities or public closure. Child lifetime is unchanged. - The leased execution journal projects neutral identity facts in its existing - Session transaction; unrequested observations are rejected. Device and engine - come from the authorized Session binding, not daemon-supplied project ownership. - The service assigns a stable ID unique within device/engine/native identity and - freezes Session, parent, native creation and first-event provenance. Conflicts - roll back the whole event batch; identical or later continuation observations - preserve the original binding. Internal reads enforce project and visible Session - scope. This is a private consumer prerequisite, not public Subagent admission, - lifecycle, child output reconstruction or complete discovery/recovery. +- Subagent resources use the common observations in + `internal/agentdaemon/proto/subagents.go`: verified identity, successful lifecycle + effects, native-owned Turns/Items and neutral coordination operations. Core + assigns public IDs and projects them under the existing Session lock and leased + execution journal. Native names, history parsing and outcome proof stay in + adapters. Public GETs read persisted resources without starting native work. + Child Turns have a native writer and a separate table from the Core queue; + `public_execution_turns` provides the shared Session read/pagination view. + Session Items stay root-owned; copied parent transcripts never become child work. + Repeated effects are idempotent. Active includes idle; task completion, process + release and cancellation cannot fabricate public closure. Native timestamps + retain their actual precision and unknown Usage stays null. + Reuse the existing native owner for child settlement and cancellation, freeze + root output first, and deliver child Items before their terminal Turn snapshot. + Do not add another scheduler or a broad recovery framework. Capability + advertisements do not qualify unsupported native facts. The exact read contract, + admission limits and remaining evidence are in [Subagents](contracts/agents-api/subagents.md). - `function_tools` advertises the optional native function-call bridge. Explicit prompt definitions become Codex dynamic tools; unchanged prompts carry none. Requests and ordered text/image results are scoped by Run and native call ID. @@ -1820,7 +1819,8 @@ for implementation and registration steps. MiniMax Code's opt-in Agents API profile qualifies native ACP 0.4.12 for `environment:none` text execution. It reuses the shared lifecycle without public -workspace, functions, MCP or native Subagents. Native configuration disables +workspace, functions or MCP. Enabled Subagents use the separately qualified +common observation path below. Native configuration disables file/shell authority and external capability discovery; the child receives a private Session home and a restricted environment. Active-input application requires a native ACP receipt, cancellation @@ -1828,7 +1828,8 @@ settles the process and output, and continuation requires the exact owned native history. Do not infer history IDs or qualify hosted execution from this text profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). -The MiniMax workspace profile retains the published CLI and isolates native +The MiniMax workspace profile builds one CLI from the fixed upstream source and +lockfile through the existing companion packaging path, and isolates native workspace tools behind its standard MCP client. The process and native Session share one private control directory; public workspace files cannot configure that process or become privileged project instructions. A trusted adapter-owned bridge @@ -1836,7 +1837,19 @@ runs the original six tool implementations in the upstream Linux sandbox, with n unsandboxed fallback. Keep native history bound to the control directory and Files/ Artifacts bound to the public workspace. Core and shared file helpers remain engine neutral. This internal MCP transport does not admit public MCP configuration. -Record published CLI and worker-source provenance separately; complete +Record the upstream revision, native admission patch hashes and worker-source +provenance. The bounded patch checks the shared descendant-task limit inside the +existing native SQLite admission transaction before start, without another +scheduler. ACP initialization must acknowledge the applied limit before input. +The native tool catalog applies the protected workspace policy to every child, +not only the root's configured profile. Only the Session's authorized internal +workspace MCP entry crosses the native child selector; this does not grant +external MCP access or bypass the native profile's read/write restrictions. +Initialization must acknowledge that protected tool policy before input as well. +Subagent reads use the Session-private protected native database. Multi-agent +workspace execution installs only the existing authorized workspace MCP entry in +that private native configuration so children inherit the same tools; public MCP +and Environment-origin MCP combinations remain separately qualified. Complete [workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) before enabling hosted execution. The standalone companion uses its own npm lock; `make check` runs its lifecycle tests and script checks, while its exact-source Linux build and @@ -2156,8 +2169,11 @@ declared functions with ordered text results, and the HTTP MCP subset described above. It rejects unsupported request options and disables built-in tools and undeclared MCP discovery. `DisableExecutionEnvironment` and `DisableSubagents` are accepted assertions about -this fixed restrictive profile. Omission does not enable built-in tools. New and -resumed queries use the SDK's empty built-in tool set, explicit function MCP +the single-Agent restrictive profile. Omission does not enable built-in tools. +Explicit Subagent observation enables only its qualified native delegation tools, +with admission before start and verified child identity before workspace authority. +Public function/MCP combinations remain unqualified with Subagents. Single-Agent +new and resumed queries use the SDK's empty built-in tool set, explicit function MCP configuration and allowlist, strict MCP configuration and empty user/project/local setting sources. Without HTTP MCP declarations, native initialization and real provider request inventories must contain only the declared host functions. Managed operator policy may further diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go index b0e15ad8d..c1e27675f 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/options.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/options.go @@ -18,7 +18,12 @@ type Config struct { Workspace *WorkspaceConfig } +type subagentOptions struct { + MaxConcurrent int `json:"max_concurrent"` +} + type startRequest struct { + Subagents *subagentOptions `json:"subagents,omitempty"` Type string `json:"type"` Prompt string `json:"prompt,omitempty"` Model string `json:"model"` @@ -61,8 +66,19 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if controls := req.ExecutionControls; controls != nil && (controls.WebSearch != "disabled" || controls.TextVerbosity != "medium") { return fail("execution controls require disabled web search and medium text verbosity") } - // The fixed SDK profile already excludes all built-in tools and subagents. - // Both restriction flags are supported; omitting them does not widen the profile. + if req.ObserveSubagentIdentities { + if req.DisableSubagents || len(req.FunctionTools) != 0 || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && len(req.LocalEnvironment.MCP) != 0) { + return fail("subagent execution does not support this tool combination") + } + limit := 6 + if req.MaxConcurrentSubagents != nil { + limit = *req.MaxConcurrentSubagents + } + if limit < 1 { + return fail("invalid concurrent subagent limit") + } + start.Subagents = &subagentOptions{MaxConcurrent: limit} + } if err := validateFunctions(req.FunctionTools); err != nil { return startRequest{}, nil, err } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go index 1a7eab652..3a39f2d44 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go @@ -36,8 +36,8 @@ func NewPreparationFactory(config Config) agent.PreparationFactory { if owner == nil { owner = context.Background() } - if config.Workspace == nil || req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.ObserveSubagentIdentities { - return nil, fmt.Errorf("claudesdk: preparation requires workspace configuration without input, conversation or subagents") + if config.Workspace == nil || req.RunID != "" || req.Prompt != "" || req.ConversationID != "" { + return nil, fmt.Errorf("claudesdk: preparation requires workspace configuration without input or conversation") } snapshot := config snapshot.Env = slices.Clone(config.Env) @@ -53,6 +53,9 @@ func NewPreparationFactory(config Config) agent.PreparationFactory { if err != nil || !info.supportsWorkspacePreparation() { return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace preparation") } + if start.Subagents != nil && !info.SupportsSubagents() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") + } if start.observeFunctions && !info.supportsWorkspaceCommands() { return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace command observations") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 30be6bd2b..3accf625a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -25,6 +25,10 @@ type RuntimeInfo struct { Features []string `json:"features"` } +func (info RuntimeInfo) SupportsSubagents() bool { + return slices.Contains(info.Features, "subagent_resources") +} + func (info RuntimeInfo) SupportsHTTPMCP() bool { return slices.Contains(info.Features, "mcp_http_tools") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go index 64d54e01b..13697df8b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -49,17 +49,22 @@ func NewFactory(config Config) agent.Factory { if err != nil { return nil, err } - if start.MCPHTTPServers != nil { + if start.MCPHTTPServers != nil || start.Subagents != nil { info, err := CheckRuntime(ctx, config) - if err != nil || !info.SupportsHTTPMCP() { + if start.Subagents != nil && (err != nil || !info.SupportsSubagents()) { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") + } + if start.MCPHTTPServers != nil && (err != nil || !info.SupportsHTTPMCP()) { return nil, fmt.Errorf("claudesdk: packaged runtime does not support HTTP MCP") } - for _, server := range *start.MCPHTTPServers { - if server.Required && !info.SupportsHTTPMCPRequired() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") - } - if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") + if start.MCPHTTPServers != nil { + for _, server := range *start.MCPHTTPServers { + if server.Required && !info.SupportsHTTPMCPRequired() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") + } + if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") + } } } } @@ -73,6 +78,7 @@ func NewFactory(config Config) agent.Factory { } type bridgeEvent struct { + Fact json.RawMessage `json:"fact"` InputID string `json:"input_id"` ResultID string `json:"result_id"` Usage json.RawMessage `json:"usage,omitempty"` @@ -207,6 +213,13 @@ func (s *session) run(ctx context.Context, runID string, start startRequest, out failure = err s.process.Cancel() } + case proto.TypeSubagentIdentity, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: + if start.Subagents == nil || !json.Valid(event.Fact) { + failure = fmt.Errorf("claudesdk: unrequested native child observation") + s.process.Cancel() + break + } + emit(event.Type, event.Fact) case "usage": if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go b/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go new file mode 100644 index 000000000..db723328e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/subagents_test.go @@ -0,0 +1,48 @@ +package claudesdk + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSubagentConfigurationUsesExplicitRequestAndFrozenLimit(t *testing.T) { + config := workspaceFixture(t) + req := workspaceRequest() + start, _, err := prepare(config, req) + if err != nil || start.Subagents != nil { + t.Fatal("ordinary execution changed", err) + } + req.DisableSubagents, req.ObserveSubagentIdentities = false, true + start, _, err = prepare(config, req) + if err != nil || start.Subagents == nil || start.Subagents.MaxConcurrent != 6 { + t.Fatal("missing default native admission limit", err) + } + limit := 2 + req.MaxConcurrentSubagents = &limit + start, _, err = prepare(config, req) + limit = 4 + if err != nil || start.Subagents.MaxConcurrent != 2 { + t.Fatal("subagent configuration was not frozen", err) + } +} + +func TestSubagentConfigurationRejectsUnqualifiedAuthority(t *testing.T) { + config := workspaceFixture(t) + for _, change := range []func(*proto.PromptRequestPayload){ + func(r *proto.PromptRequestPayload) { r.DisableSubagents = true }, + func(r *proto.PromptRequestPayload) { n := 0; r.MaxConcurrentSubagents = &n }, + func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "function"}} }, + func(r *proto.PromptRequestPayload) { v := []proto.MCPHTTPServer{}; r.MCPHTTPServers = &v }, + } { + req := workspaceRequest() + req.DisableSubagents, req.ObserveSubagentIdentities = false, true + change(&req) + if _, _, err := prepare(config, req); err == nil { + t.Fatal("unqualified subagent combination accepted") + } + } + if (RuntimeInfo{}).SupportsSubagents() || !(RuntimeInfo{Features: []string{"subagent_resources"}}).SupportsSubagents() { + t.Fatal("subagent readiness did not require the packaged feature") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index fc4c36bb5..770f97127 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -89,6 +89,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg rpc := NewJSONRPCClient(rpcCfg) s := &Session{ + nativeHome: nativeHomeFromPlan(plan), toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, functions: functions, observeMessages: req.ObserveMessages, @@ -127,6 +128,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return nil, err } } + if s.observeSubagentIdentities { + if err := verifySubagentObservationProfile(cancelCtx, rpc, plan.Cwd); err != nil { + return p.preparationFailed(err) + } + } if s.toolEnvironment { if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { return p.preparationFailed(err) diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/parsar-daemon/internal/agent/codex/prepared.go index 79707cd54..7b71ce36a 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared.go @@ -99,9 +99,15 @@ func (p *Prepared) Cancel(ctx context.Context) error { started := p.started p.mu.Unlock() if started { - err := p.session.Cancel(ctx) - <-p.session.waitDone - return err + if err := p.session.Cancel(ctx); err != nil { + return err + } + select { + case <-p.session.waitDone: + return nil + case <-ctx.Done(): + return ctx.Err() + } } return p.Close() } diff --git a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go index 3775cf13a..9f475c817 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared_cancel_test.go @@ -2,6 +2,7 @@ package codex import ( "context" + "errors" "reflect" "sync" "sync/atomic" @@ -169,6 +170,11 @@ func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { t.Fatal("Session finished before local cleanup") default: } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if err := p.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("blocked cleanup ignored caller deadline: %v", err) + } allowCleanup() select { case err := <-finished: @@ -179,6 +185,9 @@ func TestPreparedCancelTransferredWaitsForCleanup(t *testing.T) { t.Fatal("cancellation did not finish after local cleanup") } waitPreparedRelease(t, p, root) + if err := p.Cancel(t.Context()); err != nil { + t.Fatalf("settled cleanup could not be retried: %v", err) + } } func TestPreparedCancelRacingTransfer(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/codex/protocol.go b/apps/parsar-daemon/internal/agent/codex/protocol.go index f24be9802..a5f91baf5 100644 --- a/apps/parsar-daemon/internal/agent/codex/protocol.go +++ b/apps/parsar-daemon/internal/agent/codex/protocol.go @@ -158,6 +158,7 @@ type SandboxPolicy struct { // --------------------------------------------------------------------------- type ThreadStartParams struct { + HistoryMode string `json:"historyMode,omitempty"` Cwd string `json:"cwd"` Model string `json:"model,omitempty"` ModelProvider string `json:"modelProvider,omitempty"` @@ -269,9 +270,10 @@ type TurnUsage struct { } type Turn struct { - ID string `json:"id"` - Usage *TurnUsage `json:"usage,omitempty"` - Status string `json:"status,omitempty"` + CompletedAt *int64 `json:"completedAt,omitempty"` + ID string `json:"id"` + Usage *TurnUsage `json:"usage,omitempty"` + Status string `json:"status,omitempty"` // Failed Turns carry the native provider error here. Error *TurnError `json:"error,omitempty"` } diff --git a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go index 7584cf4b6..9aed5ef85 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc_close_test.go @@ -85,7 +85,18 @@ func TestJSONRPCClientCloseCanRetryUnreapedChild(t *testing.T) { } } closeConcurrently(true) + ownerCtx, cancelOwner := context.WithCancel(t.Context()) + s := &Session{rpc: client, cancelCtx: ownerCtx, cancelFn: cancelOwner, + cfg: defaultSessionConfig(), interactions: newPendingCodexInteractions(), bufs: NewItemBuffers()} + ctx, cancelWait := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancelWait() + if err := s.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Session cancellation before reap: %v", err) + } reap.Do(client.waitChild) + if err := s.Cancel(t.Context()); err != nil { + t.Fatalf("Session cancellation retry after reap: %v", err) + } closeConcurrently(false) if client.cmd != cmd || cmd.ProcessState == nil { t.Fatal("Close did not retain and reap its original child") diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index f2bf9991e..40c141c9e 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -64,6 +64,7 @@ func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- pro // this by killing the child early. type Session struct { toolEnvironment bool + nativeHome string subagents *subagentObservations observeSubagentIdentities bool functions *functionCalls @@ -78,7 +79,9 @@ type Session struct { cancelCtx context.Context cancelFn context.CancelFunc + cancelErr error cancelOnce sync.Once + cancelReady chan struct{} cancelled atomic.Bool terminal atomic.Bool closeOutOnce sync.Once @@ -243,7 +246,11 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { s.finishAfterTerminal() return } - s.emitDone(finalText, usage) + var completedAt *int64 + if status == "completed" { + completedAt = nativeMilliseconds(p.Turn.CompletedAt) + } + s.emitDoneAt(finalText, usage, completedAt) s.finishAfterTerminal() } @@ -348,6 +355,10 @@ func (s *Session) peekLastErrText() string { // --------------------------------------------------------------------------- func (s *Session) emitDone(content string, usage *TurnUsage) { + s.emitDoneAt(content, usage, nil) +} + +func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int64) { if !s.terminal.CompareAndSwap(false, true) { return } @@ -357,7 +368,7 @@ func (s *Session) emitDone(content string, usage *TurnUsage) { doneMeta[proto.DoneMetaAgentSessionID] = tid doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" } - payload := proto.DonePayload{Content: content, Metadata: doneMeta} + payload := proto.DonePayload{Content: content, Metadata: doneMeta, SourceCompletedAtMS: completedAt} if usage != nil { payload.Usage = s.usagePayload(*usage) } diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel.go b/apps/parsar-daemon/internal/agent/codex/session_cancel.go index cc7d8a709..aeaf78c96 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_cancel.go +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel.go @@ -2,25 +2,55 @@ package codex import ( "context" + "errors" "time" ) -func (s *Session) Cancel(_ context.Context) error { +func (s *Session) Cancel(ctx context.Context) error { s.cancelled.Store(true) s.cancelOnce.Do(func() { - turnID, active := s.stopSteering() - s.stopCodexInteractionTimers() - // Best effort: a known Turn must use its native identity. An explicit - // empty ID invokes native startup cancellation before turn/started. - if threadID := s.currentThreadID(); threadID != "" && active { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _, _ = s.rpc.request(ctx, "turn/interrupt", TurnInterruptParams{ThreadID: threadID, TurnID: turnID}, func(frame any) error { - return s.rpc.writeFrameContext(ctx, frame) - }) - } - s.cancelFn() - _ = s.rpc.Close() + s.cancelReady = make(chan struct{}) + go s.cancelNativeWork() }) - return nil + // A caller deadline does not terminate the owner that is still collecting + // native child cancellation facts. Another call can await the same cleanup. + select { + case <-s.cancelReady: + default: + select { + case <-s.cancelReady: + case <-ctx.Done(): + return ctx.Err() + } + } + // Close already owns shutdown once and permits another wait for process exit. + // Keep actual observation failures, but never cache a caller's wait timeout. + closed := make(chan error, 1) + go func() { closed <- s.rpc.Close() }() + select { + case err := <-closed: + return errors.Join(s.cancelErr, err) + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) cancelNativeWork() { + defer close(s.cancelReady) + turnID, active := s.stopSteering() + s.stopCodexInteractionTimers() + // Best effort: a known Turn must use its native identity. An explicit + // empty ID invokes native startup cancellation before turn/started. + if threadID := s.currentThreadID(); threadID != "" && active { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, _ = s.rpc.request(ctx, "turn/interrupt", TurnInterruptParams{ThreadID: threadID, TurnID: turnID}, func(frame any) error { + return s.rpc.writeFrameContext(ctx, frame) + }) + } + if s.subagents != nil { + s.cancelErr = s.cancelSubagentWork(context.Background()) + } + s.cancelFn() + _ = s.rpc.Close() } diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 28b541630..3fc8831a9 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -24,6 +24,10 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err != nil { return SessionPlan{}, nil, fmt.Errorf("codex: build session plan: %w", err) } + if err := configureSubagentObservations(&plan, req); err != nil { + plan.Cleanup() + return SessionPlan{}, nil, err + } if profile != "" { plan.Sandbox = "" plan.Permissions = profile diff --git a/apps/parsar-daemon/internal/agent/codex/session_thread.go b/apps/parsar-daemon/internal/agent/codex/session_thread.go index 151470741..28681b400 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_thread.go +++ b/apps/parsar-daemon/internal/agent/codex/session_thread.go @@ -15,6 +15,9 @@ func (s *Session) startThread(plan SessionPlan) error { Permissions: plan.Permissions, DeveloperInstructions: plan.SystemPrompt, } + if s.observeSubagentIdentities { + params.HistoryMode = "paginated" + } if s.functions != nil { params.DynamicTools = s.functions.definitions } diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_cancel.go b/apps/parsar-daemon/internal/agent/codex/subagent_cancel.go new file mode 100644 index 000000000..62ff38907 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_cancel.go @@ -0,0 +1,32 @@ +package codex + +import ( + "context" +) + +// The existing Run owner interrupts child work while its native reader is still +// alive. Callers bound their own wait without stopping this owner or fabricating +// a child terminal when the native cancellation takes longer than their deadline. +func (s *Session) cancelSubagentWork(ctx context.Context) error { + o := s.subagents + select { + case <-o.done: + o.mu.Lock() + defer o.mu.Unlock() + return o.cancelResult + default: + } + o.cancelling.Store(true) + select { + case o.wake <- struct{}{}: + default: + } + select { + case <-o.done: + o.mu.Lock() + defer o.mu.Unlock() + return o.cancelResult + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go new file mode 100644 index 000000000..2899dfa03 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_cancel_test.go @@ -0,0 +1,118 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSubagentCancelDeadlineRetainsOwnerAndRetry(t *testing.T) { + s, f, out := observationSession(t, "inProgress") + gate := make(chan struct{}) + release := sync.OnceFunc(func() { close(gate) }) + defer release() + f.mu.Lock() + f.interruptGate = gate + f.mu.Unlock() + s.emitDone("frozen", nil) + for range 4 { + select { + case e := <-out: + if e.Type == proto.TypeDone { + t.Fatal("root released active child") + } + case <-time.After(time.Second): + t.Fatal("child facts missing") + } + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if err := s.Cancel(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("first cancellation did not honor caller deadline: %v", err) + } + if !s.rpc.Alive() || s.cancelCtx.Err() != nil { + t.Fatal("caller deadline killed the native observation owner") + } + select { + case e := <-out: + t.Fatalf("invented terminal before native receipt: %s", e.Type) + default: + } + result := make(chan error, 4) + for range cap(result) { + go func() { result <- s.Cancel(t.Context()) }() + } + release() + for range cap(result) { + select { + case err := <-result: + if err != nil { + t.Fatal("settled cancellation retry failed", err) + } + case <-time.After(4 * time.Second): + t.Fatal("cancellation retry did not settle") + } + } + events := collectObserved(t, out) + childTerminal, rootTerminal := -1, -1 + for i, event := range events { + if event.Type == proto.TypeSubagentTurn { + var turn proto.SubagentTurnPayload + _ = event.DecodePayload(&turn) + if turn.Status == "cancelled" { + childTerminal = i + } + } + if event.Type == proto.TypeDone { + rootTerminal = i + } + } + f.mu.Lock() + defer f.mu.Unlock() + if f.interrupted != 1 || childTerminal < 0 || rootTerminal <= childTerminal { + t.Fatalf("interrupts=%d child=%d root=%d", f.interrupted, childTerminal, rootTerminal) + } +} + +func TestSubagentCancelRetainsActualObservationFailure(t *testing.T) { + for _, interrupted := range []bool{false, true} { + t.Run(map[bool]string{false: "invalid history", true: "observer stopped"}[interrupted], func(t *testing.T) { + s, f, out := observationSession(t, "completed") + if interrupted { + s.subagents.cancel() + } else { + if err := os.WriteFile(filepath.Join(f.home, "sessions", "root.jsonl"), []byte("invalid\n"), 0600); err != nil { + t.Fatal(err) + } + s.emitDone("frozen", nil) + } + select { + case <-s.subagents.done: + case <-time.After(5 * time.Second): + t.Fatal("observer did not settle") + } + for range 2 { + if err := s.Cancel(t.Context()); err == nil { + t.Fatal("lost an actual observation failure") + } + } + s.closeOut() + for _, event := range collectObserved(t, out) { + if event.Type == proto.TypeSubagentTurn { + var turn proto.SubagentTurnPayload + _ = json.Unmarshal(event.Payload, &turn) + if turn.Status != "in_progress" { + t.Fatal("invented child terminal after failed observation") + } + } + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go b/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go new file mode 100644 index 000000000..cc278a604 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_coordination.go @@ -0,0 +1,64 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func subagentCoordination(raw json.RawMessage, actor string) (*proto.SubagentCoordinationPayload, error) { + var item struct { + subagentCollaboration + Model *string `json:"model"` + ReasoningEffort *string `json:"reasoningEffort"` + } + if json.Unmarshal(raw, &item) != nil { + return nil, errors.New("codex: invalid subagent coordination Item") + } + if item.Type != "collabAgentToolCall" { + return nil, nil + } + kinds := map[string]string{"spawnAgent": "create_subagent_call", "sendInput": "send_subagent_input_call", "wait": "wait_for_subagents_call", "closeAgent": "close_subagent_call", "resumeAgent": "resume_subagent_call"} + kind, ok := kinds[item.Tool] + if !ok { + return nil, errors.New("codex: unsupported subagent coordination tool") + } + status := observationStatus(item.Status, "after") + if item.Status == "inProgress" { + status = "in_progress" + } + return &proto.SubagentCoordinationPayload{ID: item.ID, Kind: kind, Status: status, ActorID: actor, Recipients: item.Receivers, Text: item.Prompt, Model: item.Model, ReasoningEffort: item.ReasoningEffort}, nil +} + +func (s *Session) publishSubagentCoordination(ctx context.Context, h subagentHistory, known map[string]bool) error { + root := s.currentThreadID() + + s.steering.mu.Lock() + rootTurn := s.steering.id + s.steering.mu.Unlock() + for _, turn := range h.Turns { + if h.ID == root && turn.ID != rootTurn { + continue + } + for _, raw := range turn.Items { + payload, err := subagentCoordination(raw, "") + if err != nil { + return err + } + if payload == nil { + continue + } + for _, id := range payload.Recipients { + if (id == root || !known[id]) && payload.Status != "failed" { + return errors.New("codex: coordination recipient has no verified subagent identity") + } + } + if err := s.sendSubagentFact(ctx, proto.TypeSubagentCoordination, turn.ID+":"+payload.ID, payload); err != nil { + return err + } + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_history.go b/apps/parsar-daemon/internal/agent/codex/subagent_history.go new file mode 100644 index 000000000..2d017c951 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_history.go @@ -0,0 +1,87 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" +) + +type subagentHistory struct { + Thread + Parent string `json:"parentThreadId"` + HistoryMode string `json:"historyMode"` + Turns []subagentNativeTurn `json:"turns"` +} + +type subagentNativeTurn struct { + CompletedItems map[string]bool `json:"-"` + ID string `json:"id"` + Status string `json:"status"` + StartedAt *int64 `json:"startedAt"` + CompletedAt *int64 `json:"completedAt"` + ItemsView string `json:"itemsView"` + Items []json.RawMessage `json:"items"` +} + +type subagentCollaboration struct { + Type string `json:"type"` + ID string `json:"id"` + Tool string `json:"tool"` + Status string `json:"status"` + Sender string `json:"senderThreadId"` + Receivers []string `json:"receiverThreadIds"` + Prompt *string `json:"prompt"` +} + +func (s *Session) readSubagentHistory(ctx context.Context, id string) (subagentHistory, error) { + var result struct { + Thread subagentHistory `json:"thread"` + } + if err := s.subagentRequest(ctx, "thread/read", map[string]any{"threadId": id, "includeTurns": false}, &result); err != nil { + return result.Thread, err + } + h := result.Thread + if h.ID != id || h.HistoryMode != "paginated" || h.Path == "" { + return h, errors.New("codex: subagent history identity or mode unavailable") + } + var cursor *string + seen := map[string]bool{} + for range 100 { + var page struct { + Data []subagentNativeTurn `json:"data"` + NextCursor *string `json:"nextCursor"` + } + args := map[string]any{"threadId": id, "itemsView": "full", "sortDirection": "asc", "limit": 100, "cursor": cursor} + if err := s.subagentRequest(ctx, "thread/turns/list", args, &page); err != nil { + return h, err + } + if page.Data == nil { + return h, errors.New("codex: subagent Turn history unavailable") + } + for _, turn := range page.Data { + if turn.ID == "" || seen[turn.ID] || turn.ItemsView != "full" { + return h, errors.New("codex: subagent Turn history incomplete") + } + seen[turn.ID] = true + h.Turns = append(h.Turns, turn) + } + if page.NextCursor == nil { + return h, nil + } + cursor = page.NextCursor + } + return h, errors.New("codex: subagent Turn history exceeds read bound") +} + +func (s *Session) subagentRequest(ctx context.Context, method string, args, result any) error { + operation, cancel := context.WithTimeout(ctx, subagentLookupTimeout) + defer cancel() + raw, err := s.rpc.requestWithTimeout(operation, method, args, func(frame any) error { return s.rpc.writeFrameContext(operation, frame) }, 0, nil) + if err != nil { + return errors.New("codex: subagent native history query failed") + } + if json.Unmarshal(raw, result) != nil { + return errors.New("codex: invalid subagent native history") + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go b/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go index 028fb678e..8d3db0868 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_metadata.go @@ -8,6 +8,7 @@ import ( type subagentMetadata struct { ID string `json:"id"` + Nickname *string `json:"agentNickname"` ParentThreadID string `json:"parentThreadId"` CreatedAt int64 `json:"createdAt"` Source json.RawMessage `json:"source"` @@ -16,6 +17,8 @@ type subagentMetadata struct { // Parent-filtered listing reads persisted metadata. thread/read may instead // synthesize createdAt from a live snapshot before the first persistence flush. func (s *Session) persistedSubagent(ctx context.Context, child, parent string, budget *int) (subagentMetadata, bool, error) { + ctx, cancel := context.WithTimeout(ctx, subagentLookupTimeout) + defer cancel() var cursor *string for range 4 { if *budget == 0 { diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations.go b/apps/parsar-daemon/internal/agent/codex/subagent_observations.go index 50ddbb3ec..9c1a68cc3 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_observations.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_observations.go @@ -3,7 +3,9 @@ package codex import ( "context" "encoding/json" + "errors" "sync" + "sync/atomic" "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -11,155 +13,140 @@ import ( const subagentLookupTimeout = 3 * time.Second -type subagentCandidate struct{ child, parent, turn, item string } - -// One worker owns bounded metadata reads; the native reader only admits facts. type subagentObservations struct { - mu sync.Mutex - seen map[string]bool - sealed bool - queue chan subagentCandidate - ctx context.Context - cancel context.CancelFunc - done chan struct{} - published chan struct{} + cancelling atomic.Bool + cancelResult error + interrupted map[string]bool + + mu sync.Mutex + sealed bool + ctx context.Context + cancel context.CancelFunc + wake chan struct{} + terminal chan []proto.Envelope + done chan struct{} + sent map[string]string } func (s *Session) startSubagentObservations() { ctx, cancel := context.WithCancel(s.cancelCtx) - s.subagents = &subagentObservations{seen: make(map[string]bool), queue: make(chan subagentCandidate, 64), - ctx: ctx, cancel: cancel, done: make(chan struct{}), published: make(chan struct{})} - go s.collectSubagentIdentities() + s.subagents = &subagentObservations{ctx: ctx, cancel: cancel, wake: make(chan struct{}, 1), terminal: make(chan []proto.Envelope, 1), done: make(chan struct{}), sent: map[string]string{}, interrupted: map[string]bool{}} + go s.collectSubagentFacts() } +// Notifications only wake the history reader; they never establish ownership or +// infer successful lifecycle effects from the native tool's completion status. func (s *Session) observeSubagentIdentity(raw json.RawMessage) { - o := s.subagents - if o == nil { + if s.subagents == nil { return } var event struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Item struct { - ID string `json:"id"` - Type string `json:"type"` - Tool string `json:"tool"` - Status string `json:"status"` - SenderThreadID string `json:"senderThreadId"` - ReceiverThreadIDs []string `json:"receiverThreadIds"` - } `json:"item"` + ThreadID string `json:"threadId"` + Item subagentCollaboration `json:"item"` } - if json.Unmarshal(raw, &event) != nil || event.Item.Type != "collabAgentToolCall" || - (event.Item.Tool != "spawnAgent" && event.Item.Tool != "resumeAgent") { + if json.Unmarshal(raw, &event) != nil { return } - if !s.isRootTurn(event.ThreadID, event.TurnID) || event.Item.SenderThreadID != event.ThreadID || event.Item.ID == "" || event.Item.Status != "completed" { - s.subagentGap("discovery_unverified_or_late") + if event.Item.Type != "collabAgentToolCall" && event.ThreadID == s.currentThreadID() { return } - o.mu.Lock() - defer o.mu.Unlock() - for _, child := range event.Item.ReceiverThreadIDs { - if o.sealed || s.terminal.Load() || child == "" || child == event.ThreadID { - s.subagentGap("discovery_unverified_or_late") - continue - } - if o.seen[child] { - continue - } - if len(o.seen) == 64 { - s.subagentGap("discovery_capacity") - continue - } - o.seen[child] = true - o.queue <- subagentCandidate{child, event.ThreadID, event.TurnID, event.Item.ID} + select { + case s.subagents.wake <- struct{}{}: + default: } } -func (s *Session) collectSubagentIdentities() { +func (s *Session) collectSubagentFacts() { o := s.subagents defer close(o.done) - budget := 64 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + var terminal []proto.Envelope + var firstFailure time.Time + var started bool for { select { case <-o.ctx.Done(): - s.subagentGap("discovery_owner_ended") + o.mu.Lock() + o.cancelResult = o.ctx.Err() + o.mu.Unlock() return - case candidate, open := <-o.queue: - if !open { + case terminal = <-o.terminal: + started = true + case <-o.wake: + started = true + case <-ticker.C: + } + if !started { + continue + } + if s.currentThreadID() == "" { + if terminal != nil { + s.publishSubagentTerminal(terminal, nil) return } - s.resolveSubagentIdentity(candidate, &budget) + continue } - } -} - -func (s *Session) resolveSubagentIdentity(candidate subagentCandidate, budget *int) { - ctx, cancel := context.WithTimeout(s.subagents.ctx, subagentLookupTimeout) - defer cancel() - for { - metadata, found, err := s.persistedSubagent(ctx, candidate.child, candidate.parent, budget) + busy, err := s.snapshotSubagents(o.ctx) if err != nil { - s.subagentGap(err.Error()) - return - } - if found { - env, err := proto.NewEnvelope(proto.TypeSubagentIdentity, s.runID, proto.SubagentIdentityPayload{ - NativeID: metadata.ID, ParentNativeID: metadata.ParentThreadID, NativeCreatedAt: metadata.CreatedAt, - ParentTurnID: candidate.turn, SourceItemID: candidate.item, - }) - if err != nil || !s.sendWithin(ctx, env) { - s.subagentGap("identity_delivery_unavailable") + if firstFailure.IsZero() { + firstFailure = time.Now() } + if time.Since(firstFailure) < subagentLookupTimeout { + continue + } + if terminal == nil { + s.emitTerminal("codex: subagent facts could not be confirmed", true) + select { + case terminal = <-o.terminal: + case <-o.ctx.Done(): + o.mu.Lock() + o.cancelResult = o.ctx.Err() + o.mu.Unlock() + return + } + } + o.mu.Lock() + o.cancelResult = err + o.mu.Unlock() + s.publishSubagentTerminal(terminal, err) return } - select { - case <-ctx.Done(): - s.subagentGap("metadata_not_persisted") + firstFailure = time.Time{} + if terminal != nil && !busy { + s.publishSubagentTerminal(terminal, nil) return - case <-time.After(100 * time.Millisecond): } } } -func (s *Session) subagentGap(reason string) { - s.cfg.logger.Warn("codex: subagent identity observation incomplete", "run_id", s.runID, "reason", reason) +func (s *Session) publishSubagentTerminal(events []proto.Envelope, cause error) { + if cause != nil { + s.cfg.logger.Warn("codex: subagent observation failed", "run_id", s.runID, "reason", cause.Error()) + failure, _ := proto.NewEnvelope(proto.TypeError, s.runID, proto.ErrorPayload{Error: cause.Error()}) + s.trySend(failure) + } + for _, event := range events { + s.trySend(event) + } + s.closeOut() } -// The reader has already frozen terminal content/usage and sealed root mutation. -// It must remain free to read pending RPC replies while this worker settles. +// Freeze the root outcome on the native reader, then leave that reader available +// until the existing Run owner has delivered all finite child work. Cancellation +// remains the owner's boundary; there is no timeout that silently drops children. func (s *Session) sendTerminal(events ...proto.Envelope) { - emit := func() { + if s.subagents == nil { for _, event := range events { s.trySend(event) } - } - o := s.subagents - if o == nil { - emit() return } + o := s.subagents o.mu.Lock() o.sealed = true - close(o.queue) - empty := len(o.seen) == 0 o.mu.Unlock() - finish := func() { - timer := time.AfterFunc(subagentLookupTimeout, o.cancel) - <-o.done - timer.Stop() - o.cancel() - emit() - s.closeOut() - close(o.published) - } - if empty { - // No metadata read can depend on this reader. Startup failures still emit - // synchronously before run's deferred output cleanup. - finish() - } else { - go finish() - } + o.terminal <- events } func (s *Session) closeRunOutput() { @@ -167,12 +154,28 @@ func (s *Session) closeRunOutput() { o.mu.Lock() sealed := o.sealed o.mu.Unlock() - if sealed { - <-o.published - } else { + if !sealed { o.cancel() - <-o.done } + <-o.done + o.cancel() } s.closeOut() } + +func (s *Session) sendSubagentFact(ctx context.Context, kind, key string, payload any) error { + env, err := proto.NewEnvelope(kind, s.runID, payload) + if err != nil { + return err + } + value := string(env.Payload) + key = kind + ":" + key + if s.subagents.sent[key] == value { + return nil + } + if !s.sendWithin(ctx, env) { + return errors.New("codex: subagent observation delivery unavailable") + } + s.subagents.sent[key] = value + return nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go index bd0b20dea..45223670e 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_observations_test.go @@ -3,39 +3,120 @@ package codex import ( "context" "encoding/json" - "fmt" + "os" + "path/filepath" + "sync" "testing" "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const persistedChild = `{"id":"child","parentThreadId":"root","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}` -const completedSpawn = `{"threadId":"root","turnId":"turn","item":{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child"]}}` +type subagentFixture struct { + mu sync.Mutex + home string + childStatus string + interrupted int + interruptGate <-chan struct{} + childItems []json.RawMessage +} + +func (f *subagentFixture) history(id string) subagentHistory { + h := subagentHistory{Thread: Thread{ID: id, Cwd: "/workspace", CreatedAt: 100, Path: filepath.Join(f.home, "sessions", id+".jsonl"), Status: map[string]string{"type": "idle"}}, HistoryMode: "paginated"} + start, finish := int64(101), int64(103) + turn := subagentNativeTurn{ID: id + "-turn", Status: "completed", StartedAt: &start, CompletedAt: &finish, ItemsView: "full"} + if id == "root" { + turn.Items = []json.RawMessage{json.RawMessage(`{"type":"collabAgentToolCall","id":"spawn","tool":"spawnAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child"],"prompt":"real child prompt"}`)} + } else { + h.Parent = "root" + turn.Status = f.childStatus + turn.Items = f.childItems + if turn.Status == "inProgress" { + turn.CompletedAt = nil + h.Status = map[string]string{"type": "active"} + } + } + h.Turns = []subagentNativeTurn{turn} + return h +} + +func (f *subagentFixture) persist(t *testing.T) { + t.Helper() + for _, id := range []string{"root", "child"} { + h := f.history(id) + rows := []any{map[string]any{"type": "session_meta", "payload": map[string]any{"id": id, "cwd": h.Cwd}}} + for _, item := range h.Turns[0].Items { + var v map[string]any + _ = json.Unmarshal(item, &v) + rows = append(rows, map[string]any{"type": "event_msg", "payload": map[string]any{"type": "item_completed", "thread_id": id, "turn_id": id + "-turn", "completed_at_ms": 102000, "item": map[string]any{"id": v["id"], "type": "AgentMessage"}}}) + } + var body []byte + for _, row := range rows { + line, _ := json.Marshal(row) + body = append(append(body, line...), '\n') + } + if err := os.WriteFile(h.Path+".tmp", body, 0600); err != nil { + t.Fatal(err) + } + if err := os.Rename(h.Path+".tmp", h.Path); err != nil { + t.Fatal(err) + } + } +} -func identitySession(t *testing.T) (*Session, ServerSide, <-chan JsonRpcRequest, <-chan proto.Envelope) { +func observationSession(t *testing.T, status string) (*Session, *subagentFixture, <-chan proto.Envelope) { t.Helper() client, server, cleanup := NewTestClient() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - out := make(chan proto.Envelope, 64) - s := &Session{runID: "run", rpc: client.JSONRPCClient, out: out, - cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} - if err := s.bindThreadResult(json.RawMessage(`{"thread":{"id":"root"}}`), ""); err != nil { + ctx, cancel := context.WithCancel(t.Context()) + out := make(chan proto.Envelope, 128) + f := &subagentFixture{home: t.TempDir(), childStatus: status, childItems: []json.RawMessage{json.RawMessage(`{"type":"agentMessage","id":"answer","text":"child result"}`)}} + if err := os.Mkdir(filepath.Join(f.home, "sessions"), 0700); err != nil { t.Fatal(err) } - s.beginRootTurn("root", "turn") + f.persist(t) + s := &Session{runID: "run", nativeHome: f.home, rpc: client.JSONRPCClient, out: out, cancelCtx: ctx, cancelFn: cancel, cfg: defaultSessionConfig(), bufs: NewItemBuffers(), interactions: newPendingCodexInteractions()} + s.setThreadID("root") + s.beginRootTurn("root", "root-turn") s.startSubagentObservations() s.registerHandlers() - requests := make(chan JsonRpcRequest, 64) go func() { - defer close(requests) decoder := json.NewDecoder(server.FromClient) for { - var req JsonRpcRequest - if decoder.Decode(&req) != nil { + var request JsonRpcRequest + if decoder.Decode(&request) != nil { + return + } + args, _ := json.Marshal(request.Params) + var p map[string]any + _ = json.Unmarshal(args, &p) + f.mu.Lock() + id, _ := p["threadId"].(string) + var result any + switch request.Method { + case "thread/read": + result = map[string]any{"thread": f.history(id)} + case "thread/turns/list": + result = map[string]any{"data": f.history(id).Turns, "nextCursor": nil} + case "thread/list": + result = map[string]any{"data": []any{map[string]any{"id": "child", "parentThreadId": "root", "createdAt": 100, "agentNickname": "Child", "source": map[string]any{"subAgent": map[string]any{"thread_spawn": map[string]any{"parent_thread_id": "root"}}}}}, "nextCursor": nil} + case "turn/interrupt": + gate := f.interruptGate + f.mu.Unlock() + if gate != nil { + <-gate + } + f.mu.Lock() + f.interrupted++ + f.childStatus = "interrupted" + f.persist(t) + result = map[string]any{} + default: + result = map[string]any{} + } + f.mu.Unlock() + if json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": result}) != nil { return } - requests <- req } }() t.Cleanup(func() { @@ -44,154 +125,112 @@ func identitySession(t *testing.T) (*Session, ServerSide, <-chan JsonRpcRequest, select { case <-s.subagents.done: case <-time.After(time.Second): - t.Error("metadata worker outlived owner") + t.Error("observer outlived owner") } }) - return s, server, requests, out + return s, f, out } -func identityRequest(t *testing.T, requests <-chan JsonRpcRequest) JsonRpcRequest { +func collectObserved(t *testing.T, out <-chan proto.Envelope) []proto.Envelope { t.Helper() - select { - case request := <-requests: - return request - case <-time.After(time.Second): - t.Fatal("metadata request absent") + var all []proto.Envelope + for { + select { + case event, open := <-out: + if !open { + return all + } + all = append(all, event) + case <-time.After(5 * time.Second): + t.Fatal("observation did not settle") + return nil + } } - return JsonRpcRequest{} } -func identityReply(t *testing.T, server ServerSide, request JsonRpcRequest, result string) { - t.Helper() - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": json.RawMessage(result)}); err != nil { - t.Fatal(err) +func TestSubagentFactsPrecedeFrozenRootTerminal(t *testing.T) { + s, _, out := observationSession(t, "completed") + s.onTurnCompleted(json.RawMessage(`{"threadId":"root","turn":{"id":"root-turn","status":"completed","completedAt":102}}`)) + events := collectObserved(t, out) + kinds := []string{proto.TypeSubagentIdentity, proto.TypeSubagentCoordination, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentTurn, proto.TypeDone} + if len(events) != len(kinds) { + t.Fatalf("unexpected observations: %#v", events) } -} - -func TestSubagentIdentitySettlesBeforeFrozenRootTerminal(t *testing.T) { - s, server, requests, out := identitySession(t) - s.observeSubagentIdentity(json.RawMessage(completedSpawn)) - s.observeSubagentIdentity(json.RawMessage(completedSpawn)) - request := identityRequest(t, requests) - if request.Method != "thread/list" { - t.Fatal(request.Method) - } - params, _ := json.Marshal(request.Params) - var query map[string]any - _ = json.Unmarshal(params, &query) - if query["parentThreadId"] != "root" || query["useStateDbOnly"] != true || query["limit"] != float64(100) || len(query["modelProviders"].([]any)) != 0 { - t.Fatal("lookup is not explicitly persisted and parent-scoped", string(params)) - } - notify := func(method, raw string) { - t.Helper() - if err := SendNotification(server, method, json.RawMessage(raw)); err != nil { - t.Fatal(err) + for i, event := range events { + if event.Type != kinds[i] { + t.Fatalf("event %d: %s", i, event.Type) } } - notify("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"root-message","text":"root result"}}`) - notify("turn/completed", `{"threadId":"child","turn":{"id":"child-turn","status":"completed"}}`) - notify("turn/completed", `{"threadId":"root","turn":{"id":"turn","status":"completed"}}`) - barrier := make(chan struct{}, 1) - s.rpc.OnNotification("test/barrier", func(json.RawMessage) { barrier <- struct{}{} }) - notify("test/barrier", `{}`) - select { - case <-barrier: - case <-time.After(time.Second): - t.Fatal("root terminal blocked the native reader") + var identity proto.SubagentIdentityPayload + _ = events[0].DecodePayload(&identity) + if identity.Instructions == nil || *identity.Instructions != "real child prompt" || identity.Name == nil || *identity.Name != "Child" { + t.Fatal(identity) } - notify("item/completed", `{"threadId":"root","turnId":"turn","item":{"type":"agentMessage","id":"late","text":"late mutation"}}`) - identityReply(t, server, request, `{"data":[`+persistedChild+`],"nextCursor":null}`) - var kinds []string - for { + var turn proto.SubagentTurnPayload + _ = events[4].DecodePayload(&turn) + if turn.Status != "completed" || turn.CompletedAtMS == nil || *turn.CompletedAtMS != 103000 || turn.Usage != nil { + t.Fatal(turn) + } + var done proto.DonePayload + _ = events[5].DecodePayload(&done) + if done.SourceCompletedAtMS == nil || *done.SourceCompletedAtMS != 102000 { + t.Fatal(done) + } +} + +func TestSubagentRootFirstRetainsChildUntilTerminal(t *testing.T) { + s, f, out := observationSession(t, "inProgress") + s.emitDone("frozen", nil) + for i := 0; i < 4; i++ { select { - case env, open := <-out: - if !open { - if len(kinds) != 2 || kinds[0] != proto.TypeSubagentIdentity || kinds[1] != proto.TypeDone { - t.Fatal("identity missing, duplicated or delivered after root terminal", kinds) - } - return - } - kinds = append(kinds, env.Type) - if env.Type == proto.TypeSubagentIdentity { - var value proto.SubagentIdentityPayload - if env.DecodePayload(&value) != nil || value.NativeID != "child" || value.ParentNativeID != "root" || value.NativeCreatedAt != 100 || value.ParentTurnID != "turn" || value.SourceItemID != "spawn" { - t.Fatal(value) - } - } - if env.Type == proto.TypeDone { - var done proto.DonePayload - if env.DecodePayload(&done) != nil || done.Content != "root result" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" { - t.Fatal("pending metadata changed frozen root outcome", done) - } + case e := <-out: + if e.Type == proto.TypeDone { + t.Fatal("root released active child") } case <-time.After(time.Second): - t.Fatal("settled metadata did not release root terminal") + t.Fatal("child facts missing") } } -} - -func TestSubagentIdentityRejectsUnverifiedMetadata(t *testing.T) { - for _, row := range []string{ - `{"id":"child","parentThreadId":"foreign","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}`, - `{"id":"child","parentThreadId":"root","createdAt":100,"source":{"subAgent":{"thread_spawn":{"parent_thread_id":"foreign"}}}}`, - `{"id":"child","parentThreadId":"root","createdAt":100,"source":"cli"}`, - `{"id":"child","parentThreadId":"root","source":{"subAgent":{"thread_spawn":{"parent_thread_id":"root"}}}}`, - } { - t.Run(row, func(t *testing.T) { - s, server, requests, out := identitySession(t) - s.observeSubagentIdentity(json.RawMessage(completedSpawn)) - request := identityRequest(t, requests) - s.emitDone("root result", nil) - identityReply(t, server, request, `{"data":[`+row+`],"nextCursor":null}`) - select { - case env := <-out: - if env.Type != proto.TypeDone { - t.Fatal("unverified identity escaped", env.Type) - } - case <-time.After(time.Second): - t.Fatal("failed verification blocked terminal") - } - }) + select { + case e := <-out: + t.Fatalf("unexpected terminal: %s", e.Type) + case <-time.After(20 * time.Millisecond): + } + f.mu.Lock() + f.childStatus = "completed" + f.persist(t) + f.mu.Unlock() + events := collectObserved(t, out) + if events[len(events)-1].Type != proto.TypeDone { + t.Fatal(events) } } -func TestSubagentIdentityRetriesPersistenceAndUsesOpaqueCursor(t *testing.T) { - s, server, requests, out := identitySession(t) - s.observeSubagentIdentity(json.RawMessage(completedSpawn)) - identityReply(t, server, identityRequest(t, requests), `{"data":[],"nextCursor":null}`) - identityReply(t, server, identityRequest(t, requests), `{"data":[],"nextCursor":"opaque-native-cursor"}`) - request := identityRequest(t, requests) - params, _ := json.Marshal(request.Params) - var query map[string]any - _ = json.Unmarshal(params, &query) - if query["cursor"] != "opaque-native-cursor" { - t.Fatal(string(params)) - } - identityReply(t, server, request, `{"data":[`+persistedChild+`],"nextCursor":null}`) - select { - case env := <-out: - if env.Type != proto.TypeSubagentIdentity { - t.Fatal(env.Type) +func TestSubagentCancellationAfterRootFrozenCollectsNativeTerminal(t *testing.T) { + s, f, out := observationSession(t, "inProgress") + s.emitDone("frozen", nil) + for i := 0; i < 4; i++ { + select { + case <-out: + case <-time.After(time.Second): + t.Fatal("child not active") } - case <-time.After(time.Second): - t.Fatal("persisted child not observed") } -} - -func TestSubagentIdentityTerminalDeadlineBoundsQueuedDiscovery(t *testing.T) { - s, _, requests, out := identitySession(t) - for i := range 64 { - s.observeSubagentIdentity(json.RawMessage(fmt.Sprintf(`{"threadId":"root","turnId":"turn","item":{"id":"spawn-%d","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child-%d"]}}`, i, i))) - } - identityRequest(t, requests) // The native reader accepts the request but gives no reply. - started := time.Now() - s.emitDone("root result", nil) - select { - case env := <-out: - if env.Type != proto.TypeDone || time.Since(started) > 4*time.Second { - t.Fatal("queued lookups extended root lifetime", env.Type) + if err := s.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + events := collectObserved(t, out) + var cancelled bool + for _, event := range events { + if event.Type == proto.TypeSubagentTurn { + var turn proto.SubagentTurnPayload + _ = event.DecodePayload(&turn) + cancelled = turn.Status == "cancelled" } - case <-time.After(5 * time.Second): - t.Fatal("metadata worker did not respect shared terminal deadline") + } + f.mu.Lock() + defer f.mu.Unlock() + if f.interrupted != 1 || !cancelled { + t.Fatalf("interrupts=%d cancelled=%v", f.interrupted, cancelled) } } diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go new file mode 100644 index 000000000..905027d35 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go @@ -0,0 +1,90 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "slices" + stdstrconv "strconv" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPayload) error { + if !req.ObserveSubagentIdentities || req.DisableSubagents { + return nil + } + if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { + return errors.New("codex: multi_agent with initialized tool environment requires verified child hook failure handling") + } + for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { + plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) + if !slices.Contains(plan.DisableFeatures, feature) { + plan.DisableFeatures = append(plan.DisableFeatures, feature) + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features." + feature, "false"}) + } + plan.DisableFeatures = slices.DeleteFunc(plan.DisableFeatures, func(value string) bool { return value == "multi_agent" }) + if !slices.Contains(plan.EnableFeatures, "multi_agent") { + plan.EnableFeatures = append(plan.EnableFeatures, "multi_agent") + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.multi_agent", "true"}, [2]string{"agents.max_depth", "64"}) + if req.MaxConcurrentSubagents != nil { + if *req.MaxConcurrentSubagents < 1 { + return errors.New("codex: invalid subagent concurrency limit") + } + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"agents.max_threads", stdstrconv.Itoa(*req.MaxConcurrentSubagents)}) + } + return nil +} + +func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient, cwd string) error { + hooks, err := readNativeToolHooks(ctx, rpc, cwd) + if err != nil { + return err + } + // Packaged managed requirements may force hooks on. Only the immutable + // Bash pre-hook is admissible; managed-only discovery must survive reloads. + for _, hook := range hooks { + if !hook.isRuntimeToolEnvironmentHook() { + return errors.New("codex: subagent observation requires all hooks except the packaged Bash pre-hook disabled") + } + } + + raw, err := rpc.Request(ctx, "configRequirements/read", nil) + var response struct { + Requirements *struct { + AllowManagedHooksOnly bool `json:"allowManagedHooksOnly"` + Features map[string]bool `json:"featureRequirements"` + } `json:"requirements"` + } + if err != nil || json.Unmarshal(raw, &response) != nil { + return errors.New("codex: subagent hook requirements unavailable") + } + if response.Requirements == nil { + if len(hooks) != 0 { + return errors.New("codex: managed-only subagent hooks are not enforced") + } + return nil + } + requirements := response.Requirements + if (len(hooks) != 0 || requirements.Features["hooks"]) && !requirements.AllowManagedHooksOnly { + return errors.New("codex: managed-only subagent hooks are not enforced") + } + for _, feature := range []string{"plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { + if requirements.Features[feature] { + return errors.New("codex: required feature conflicts with subagent observation profile") + } + } + return nil +} + +func nativeHomeFromPlan(plan SessionPlan) string { + for _, value := range plan.Env { + if strings.HasPrefix(value, "CODEX_HOME=") { + return strings.TrimPrefix(value, "CODEX_HOME=") + } + } + return "" +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go new file mode 100644 index 000000000..62613cbd7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go @@ -0,0 +1,71 @@ +package codex + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { + limit := 6 + plan := SessionPlan{EnableFeatures: []string{"hooks", "code_mode", "plugins", "multi_agent_v2"}, DisableFeatures: []string{"multi_agent"}} + if err := configureSubagentObservations(&plan, proto.PromptRequestPayload{ObserveSubagentIdentities: true, MaxConcurrentSubagents: &limit}); err != nil { + t.Fatal(err) + } + for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { + if slices.Contains(plan.EnableFeatures, feature) || !slices.Contains(plan.DisableFeatures, feature) { + t.Fatal(feature, plan) + } + } + if !slices.Contains(plan.ExtraConfig, [2]string{"agents.max_threads", "6"}) || !slices.Contains(plan.ExtraConfig, [2]string{"agents.max_depth", "64"}) { + t.Fatal(plan.ExtraConfig) + } +} + +func TestSubagentProfileDoesNotDisableRequiredToolEnvironment(t *testing.T) { + request := proto.PromptRequestPayload{ObserveSubagentIdentities: true, LocalEnvironment: &proto.LocalEnvironment{ToolEnvironment: true}} + if err := configureSubagentObservations(&SessionPlan{}, request); err == nil { + t.Fatal("incompatible hook profile accepted") + } + request.DisableSubagents = true + if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { + t.Fatal("single-agent tool environment changed", err) + } +} + +func TestSubagentProfileRejectsRequirementsThatPermitResultRewrite(t *testing.T) { + for _, test := range []struct { + name string + requirements any + allowed bool + }{ + {"ordinary disabled hooks", nil, true}, + {"managed-only hooks", map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}, true}, + {"unrestricted forced hooks", map[string]any{"allowManagedHooksOnly": false, "featureRequirements": map[string]bool{"hooks": true}}, false}, + {"forced code mode", map[string]any{"featureRequirements": map[string]bool{"code_mode": true}}, false}, + {"forced plugins", map[string]any{"featureRequirements": map[string]bool{"plugins": true}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + client, server, cleanup := NewTestClient() + defer cleanup() + done := make(chan error, 1) + go func() { done <- verifySubagentObservationProfile(t.Context(), client.JSONRPCClient, "/workspace") }() + decoder := json.NewDecoder(server.FromClient) + encoder := json.NewEncoder(server.ToClient) + for _, result := range []any{map[string]any{"data": []any{map[string]any{"cwd": "/workspace", "hooks": []any{}, "errors": []any{}}}}, map[string]any{"requirements": test.requirements}} { + var request JsonRpcRequest + if err := decoder.Decode(&request); err != nil { + t.Fatal(err) + } + if err := encoder.Encode(map[string]any{"id": request.ID, "result": result}); err != nil { + t.Fatal(err) + } + } + if err := <-done; (err == nil) != test.allowed { + t.Fatal("profile admission", err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_rollout.go b/apps/parsar-daemon/internal/agent/codex/subagent_rollout.go new file mode 100644 index 000000000..f63f264f7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_rollout.go @@ -0,0 +1,267 @@ +package codex + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type subagentEffect struct { + proto.SubagentLifecyclePayload + source, turn, call string +} +type rolloutCall struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Arguments string `json:"arguments"` + CallID string `json:"call_id"` +} +type rolloutCompletion struct { + Thread string `json:"thread_id"` + Turn string `json:"turn_id"` + CompletedAt int64 `json:"completed_at_ms"` + Item struct { + Type string `json:"type"` + ID string `json:"id"` + Tool string `json:"tool"` + Status string `json:"status"` + Sender string `json:"sender_thread_id"` + Receivers []string `json:"receiver_thread_ids"` + } `json:"item"` +} + +// The outer rollout timestamp is a write time. Only correlated native +// ItemCompleted.completed_at_ms may timestamp a successful lifecycle effect. +func readSubagentEffects(home string, h *subagentHistory) ([]subagentEffect, error) { + rel, err := filepath.Rel(home, h.Path) + if !filepath.IsAbs(home) || !filepath.IsAbs(h.Path) || err != nil || !strings.HasPrefix(rel, "sessions"+string(filepath.Separator)) { + return nil, errors.New("codex: subagent history escaped private native home") + } + root, err := os.OpenRoot(home) + if err != nil { + return nil, errors.New("codex: private subagent history unavailable") + } + defer root.Close() + f, err := root.Open(rel) + if err != nil { + return nil, errors.New("codex: subagent rollout unavailable") + } + defer f.Close() + stat, err := f.Stat() + if err != nil || !stat.Mode().IsRegular() || stat.Size() > 64<<20 { + return nil, errors.New("codex: subagent rollout exceeds read bound") + } + body, err := io.ReadAll(io.LimitReader(f, (64<<20)+1)) + if err != nil || len(body) > 64<<20 { + return nil, errors.New("codex: subagent rollout read failed") + } + if len(body) == 0 || body[len(body)-1] != '\n' { + return nil, errors.New("codex: subagent rollout not flushed") + } + calls := map[string]rolloutCall{} + outputs := map[string]json.RawMessage{} + done := map[string]rolloutCompletion{} + verified := false + owners := map[string]string{} + ownedItems := map[string]map[string]bool{} + for _, line := range bytes.Split(body, []byte{'\n'}) { + if len(line) == 0 { + continue + } + var row struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if json.Unmarshal(line, &row) != nil { + return nil, errors.New("codex: invalid subagent rollout") + } + var kind struct { + Type string `json:"type"` + } + if json.Unmarshal(row.Payload, &kind) != nil { + return nil, errors.New("codex: invalid subagent rollout payload") + } + switch row.Type { + case "session_meta": + if verified { + continue + } + var meta struct { + ID string `json:"id"` + Cwd string `json:"cwd"` + } + if json.Unmarshal(row.Payload, &meta) != nil || meta.ID != h.ID || meta.Cwd != h.Cwd { + return nil, errors.New("codex: subagent rollout ownership mismatch") + } + verified = true + case "response_item": + if kind.Type == "function_call" { + var call rolloutCall + if json.Unmarshal(row.Payload, &call) != nil { + return nil, errors.New("codex: invalid lifecycle call") + } + if call.Name != "close_agent" && call.Name != "resume_agent" { + continue + } + if call.Namespace != "multi_agent_v1" || call.CallID == "" { + return nil, errors.New("codex: lifecycle call is not a direct native tool") + } + if _, ok := calls[call.CallID]; ok { + return nil, errors.New("codex: duplicate lifecycle call") + } + calls[call.CallID] = call + } else if kind.Type == "function_call_output" { + var output struct { + CallID string `json:"call_id"` + Output json.RawMessage `json:"output"` + } + if json.Unmarshal(row.Payload, &output) != nil { + return nil, errors.New("codex: invalid lifecycle output") + } + if prior, ok := outputs[output.CallID]; ok && !bytes.Equal(prior, output.Output) { + return nil, errors.New("codex: conflicting lifecycle output") + } + outputs[output.CallID] = output.Output + } + case "event_msg": + if kind.Type != "item_completed" { + continue + } + var completed rolloutCompletion + if json.Unmarshal(row.Payload, &completed) != nil { + return nil, errors.New("codex: invalid lifecycle completion") + } + if completed.Thread == "" || completed.Turn == "" { + return nil, errors.New("codex: native Item owner unavailable") + } + if prior, ok := owners[completed.Turn]; ok && prior != completed.Thread { + return nil, errors.New("codex: conflicting native Turn owner") + } + owners[completed.Turn] = completed.Thread + if ownedItems[completed.Turn] == nil { + ownedItems[completed.Turn] = map[string]bool{} + } + ownedItems[completed.Turn][completed.Item.ID] = true + if completed.Item.Type != "CollabAgentToolCall" || (completed.Item.Tool != "close_agent" && completed.Item.Tool != "resume_agent") { + continue + } + if _, ok := done[completed.Item.ID]; ok { + return nil, errors.New("codex: duplicate lifecycle completion") + } + done[completed.Item.ID] = completed + } + } + if !verified { + return nil, errors.New("codex: missing subagent rollout identity") + } + owned := make([]subagentNativeTurn, 0, len(h.Turns)) + for _, turn := range h.Turns { + owner, ok := owners[turn.ID] + if !ok { + if turn.Status == "inProgress" { + continue + } + return nil, errors.New("codex: native child Turn owner not persisted") + } + if owner == h.ID { + turn.CompletedItems = ownedItems[turn.ID] + owned = append(owned, turn) + } + } + h.Turns = owned + active := false + covered := map[string]bool{} + for _, turn := range h.Turns { + if turn.Status == "inProgress" { + active = true + } + } + var effects []subagentEffect + for _, turn := range h.Turns { + for _, raw := range turn.Items { + var item subagentCollaboration + if json.Unmarshal(raw, &item) != nil { + return nil, errors.New("codex: invalid native history item") + } + if item.Type != "collabAgentToolCall" || (item.Tool != "closeAgent" && item.Tool != "resumeAgent") { + continue + } + covered[item.ID] = true + if item.Status == "inProgress" { + if turn.Status != "inProgress" { + return nil, errors.New("codex: lifecycle effect remains unconfirmed in a terminal Turn") + } + continue + } + completion, ok := done[item.ID] + call, hasCall := calls[item.ID] + output, hasOutput := outputs[item.ID] + if !ok || !hasCall || !hasOutput { + return nil, errors.New("codex: lifecycle outcome is not durably confirmed") + } + if completion.Thread != h.ID || completion.Turn != turn.ID || completion.Item.Sender != h.ID || completion.Item.ID != item.ID || completion.Item.Tool != call.Name || completion.CompletedAt <= 0 || completion.Item.Status != item.Status { + return nil, errors.New("codex: lifecycle correlation mismatch") + } + if item.Status == "failed" { + continue + } + if item.Status != "completed" || len(item.Receivers) != 1 || len(completion.Item.Receivers) != 1 || completion.Item.Receivers[0] != item.Receivers[0] { + return nil, errors.New("codex: lifecycle target is unverified") + } + var args map[string]json.RawMessage + var target string + field, key, status := "target", "previous_status", "closed" + if call.Name == "resume_agent" { + field, key, status = "id", "status", "active" + } + if json.Unmarshal([]byte(call.Arguments), &args) != nil || json.Unmarshal(args[field], &target) != nil || target != item.Receivers[0] { + return nil, errors.New("codex: lifecycle target mismatch") + } + var text string + var receipt map[string]json.RawMessage + if json.Unmarshal(output, &text) != nil || json.Unmarshal([]byte(text), &receipt) != nil || len(receipt) != 1 || !validSubagentStatus(receipt[key]) { + return nil, errors.New("codex: lifecycle success receipt unavailable") + } + effects = append(effects, subagentEffect{proto.SubagentLifecyclePayload{NativeID: target, EffectID: h.ID + ":" + item.ID, Status: status, OccurredAtMS: completion.CompletedAt}, h.ID, turn.ID, item.ID}) + } + } + for call := range calls { + if _, ok := done[call]; !ok && !active { + return nil, errors.New("codex: lifecycle call has no confirmed terminal outcome") + } + } + for id, event := range done { + if event.Thread == h.ID && !covered[id] { + return nil, errors.New("codex: lifecycle history snapshot is incomplete") + } + } + return effects, nil +} + +func validSubagentStatus(raw json.RawMessage) bool { + var text string + if json.Unmarshal(raw, &text) == nil { + switch text { + case "pending_init", "running", "interrupted", "shutdown", "not_found": + return true + } + return false + } + var value map[string]json.RawMessage + if json.Unmarshal(raw, &value) != nil || len(value) != 1 { + return false + } + if body, ok := value["completed"]; ok { + return bytes.Equal(body, []byte("null")) || json.Unmarshal(body, &text) == nil + } + if body, ok := value["errored"]; ok { + return json.Unmarshal(body, &text) == nil + } + return false +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_rollout_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_rollout_test.go new file mode 100644 index 000000000..69489aeb3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_rollout_test.go @@ -0,0 +1,113 @@ +package codex + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func lifecycleRollout(t *testing.T, change func([]map[string]any)) (string, subagentHistory) { + t.Helper() + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "sessions"), 0700); err != nil { + t.Fatal(err) + } + h := subagentHistory{Thread: Thread{ID: "root", Cwd: "/workspace", Path: filepath.Join(home, "sessions", "root.jsonl")}, Turns: []subagentNativeTurn{{ID: "turn", Status: "completed", Items: []json.RawMessage{json.RawMessage(`{"type":"collabAgentToolCall","id":"call","tool":"closeAgent","status":"completed","senderThreadId":"root","receiverThreadIds":["child"]}`)}}}} + rows := []map[string]any{ + {"type": "session_meta", "payload": map[string]any{"id": "root", "cwd": "/workspace"}}, + {"type": "response_item", "payload": map[string]any{"type": "function_call", "name": "close_agent", "namespace": "multi_agent_v1", "call_id": "call", "arguments": `{"target":"child"}`}}, + {"type": "event_msg", "timestamp": "2099-01-01T00:00:00Z", "payload": map[string]any{"type": "item_completed", "thread_id": "root", "turn_id": "turn", "completed_at_ms": 123456, "item": map[string]any{"type": "CollabAgentToolCall", "id": "call", "tool": "close_agent", "status": "completed", "sender_thread_id": "root", "receiver_thread_ids": []string{"child"}}}}, + {"type": "response_item", "payload": map[string]any{"type": "function_call_output", "call_id": "call", "output": `{"previous_status":{"completed":"result"}}`}}, + } + if change != nil { + change(rows) + } + var body []byte + for _, row := range rows { + line, _ := json.Marshal(row) + body = append(append(body, line...), '\n') + } + if err := os.WriteFile(h.Path, body, 0600); err != nil { + t.Fatal(err) + } + return home, h +} + +func TestSubagentLifecycleRequiresCorrelatedNativeReceipt(t *testing.T) { + home, h := lifecycleRollout(t, nil) + effects, err := readSubagentEffects(home, &h) + if err != nil || len(effects) != 1 { + t.Fatal(effects, err) + } + e := effects[0] + if e.NativeID != "child" || e.Status != "closed" || e.OccurredAtMS != 123456 || e.EffectID != "root:call" { + t.Fatal(e) + } + for name, change := range map[string]func([]map[string]any){ + "missing output": func(rows []map[string]any) { rows[3]["type"] = "other" }, + "error after completed notification": func(rows []map[string]any) { rows[3]["payload"].(map[string]any)["output"] = "native error" }, + "no original time": func(rows []map[string]any) { rows[2]["payload"].(map[string]any)["completed_at_ms"] = 0 }, + "foreign target": func(rows []map[string]any) { rows[1]["payload"].(map[string]any)["arguments"] = `{"target":"foreign"}` }, + "wrong call": func(rows []map[string]any) { rows[3]["payload"].(map[string]any)["call_id"] = "other" }, + "wrong namespace": func(rows []map[string]any) { rows[1]["payload"].(map[string]any)["namespace"] = "rewritten" }, + "foreign file": func(rows []map[string]any) { rows[0]["payload"].(map[string]any)["id"] = "foreign" }, + } { + t.Run(name, func(t *testing.T) { + home, h := lifecycleRollout(t, change) + if _, err := readSubagentEffects(home, &h); err == nil { + t.Fatal("unconfirmed effect accepted") + } + }) + } +} + +func TestSubagentRolloutRejectsUnresolvedLifecycleInTerminalTurn(t *testing.T) { + home, h := lifecycleRollout(t, func(rows []map[string]any) { rows[2]["type"] = "other" }) + // Another completed Item proves Turn ownership while close has no completion. + file, err := os.OpenFile(h.Path, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, err = file.WriteString(`{"type":"event_msg","payload":{"type":"item_completed","thread_id":"root","turn_id":"turn","item":{"type":"AgentMessage","id":"answer"}}}` + "\n") + _ = file.Close() + if err != nil { + t.Fatal(err) + } + h.Turns[0].Items = nil + if _, err := readSubagentEffects(home, &h); err == nil { + t.Fatal("lost unknown close effect") + } +} + +func TestSubagentRolloutFiltersInheritedTurnOwnership(t *testing.T) { + home, h := lifecycleRollout(t, nil) + h.ID = "child" + body, err := os.ReadFile(h.Path) + if err != nil { + t.Fatal(err) + } + meta, _ := json.Marshal(map[string]any{"type": "session_meta", "payload": map[string]any{"id": "child", "cwd": "/workspace"}}) + // A fork carries ancestor records, including their original thread IDs. + if err := os.WriteFile(h.Path, append(append(meta, '\n'), body...), 0600); err != nil { + t.Fatal(err) + } + effects, err := readSubagentEffects(home, &h) + if err != nil || len(effects) != 0 || len(h.Turns) != 0 { + t.Fatal("inherited parent work became child work", h.Turns, effects, err) + } +} + +func TestSubagentRolloutRejectsEscapingSymlink(t *testing.T) { + home, h := lifecycleRollout(t, nil) + outside := filepath.Join(t.TempDir(), "outside.jsonl") + if err := os.Rename(h.Path, outside); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, h.Path); err != nil { + t.Fatal(err) + } + if _, err := readSubagentEffects(home, &h); err == nil { + t.Fatal("followed history outside private home") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go b/apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go new file mode 100644 index 000000000..e890da6cc --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/subagent_snapshots.go @@ -0,0 +1,312 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "sort" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) snapshotSubagents(ctx context.Context) (bool, error) { + root := s.currentThreadID() + queue := []string{root} + known := map[string]bool{root: true} + parents := map[string]string{} + opened := map[string]int64{} + var histories []subagentHistory + var effects []subagentEffect + busy := false + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + h, err := s.readSubagentHistory(ctx, id) + if err != nil { + return false, err + } + if id != root && h.Parent != parents[id] { + return false, errors.New("codex: child history parent mismatch") + } + if id != root { + h.CreatedAt = opened[id] + } + observed, err := readSubagentEffects(s.nativeHome, &h) + if err != nil { + return false, err + } + effects = append(effects, observed...) + histories = append(histories, h) + for _, turn := range h.Turns { + for _, raw := range turn.Items { + var item subagentCollaboration + if json.Unmarshal(raw, &item) != nil { + return false, errors.New("codex: invalid subagent discovery item") + } + if item.Type != "collabAgentToolCall" || item.Tool != "spawnAgent" || item.Status != "completed" { + continue + } + if item.Sender != id || item.ID == "" || len(item.Receivers) != 1 { + return false, errors.New("codex: subagent spawn identity is unverified") + } + child := item.Receivers[0] + if child == "" || child == root { + return false, errors.New("codex: invalid subagent tree") + } + if known[child] { + if parents[child] != id { + return false, errors.New("codex: conflicting child ancestry") + } + continue + } + if len(known) >= 65 { + return false, errors.New("codex: subagent tree exceeds read bound") + } + budget := 64 + metadata, found, err := s.persistedSubagent(ctx, child, id, &budget) + if err != nil { + return false, err + } + if !found { + return false, errors.New("codex: subagent identity not yet persisted") + } + identity := proto.SubagentIdentityPayload{NativeID: child, ParentNativeID: id, NativeCreatedAt: metadata.CreatedAt, ParentTurnID: turn.ID, SourceItemID: item.ID, Name: metadata.Nickname, Instructions: item.Prompt} + if err = s.sendSubagentFact(ctx, proto.TypeSubagentIdentity, child, identity); err != nil { + return false, err + } + known[child] = true + parents[child] = id + opened[child] = metadata.CreatedAt + queue = append(queue, child) + } + } + } + // Reconstruct transitions from the complete tree each time. A successful native + // resume of an already-active identity does not reopen it in the public model. + sort.SliceStable(effects, func(i, j int) bool { + if effects[i].OccurredAtMS == effects[j].OccurredAtMS { + return effects[i].EffectID < effects[j].EffectID + } + return effects[i].OccurredAtMS < effects[j].OccurredAtMS + }) + closed := map[string]bool{} + lastEffect := map[string]subagentEffect{} + for _, effect := range effects { + if !known[effect.NativeID] || effect.NativeID == root { + return false, errors.New("codex: lifecycle effect targets an unverified child") + } + if previous, ok := lastEffect[effect.NativeID]; ok && previous.OccurredAtMS == effect.OccurredAtMS && previous.Status != effect.Status { + return false, errors.New("codex: lifecycle effect order is ambiguous") + } + lastEffect[effect.NativeID] = effect + if effect.Status == "active" && !closed[effect.NativeID] { + continue + } + closed[effect.NativeID] = effect.Status == "closed" + if err := s.sendSubagentFact(ctx, proto.TypeSubagentLifecycle, effect.EffectID, effect.SubagentLifecyclePayload); err != nil { + return false, err + } + } + for _, h := range histories { + if h.ID == root { + if err := s.publishSubagentCoordination(ctx, h, known); err != nil { + return false, err + } + } + if h.ID != root { + active, err := s.publishSubagentHistory(ctx, h, known) + if err != nil { + return false, err + } + busy = busy || active + } + } + return busy, nil +} + +func (s *Session) publishSubagentHistory(ctx context.Context, h subagentHistory, known map[string]bool) (bool, error) { + busy := false + var nativeStatus struct { + Type string `json:"type"` + } + rawStatus, _ := json.Marshal(h.Status) + if json.Unmarshal(rawStatus, &nativeStatus) != nil { + return false, errors.New("codex: native child execution status unavailable") + } + for _, turn := range h.Turns { + status := turn.Status + switch status { + case "inProgress": + if nativeStatus.Type == "notLoaded" { + return false, errors.New("codex: unfinished child work has no live native owner") + } + status = "in_progress" + busy = true + if s.subagents.cancelling.Load() { + key := h.ID + ":" + turn.ID + if !s.subagents.interrupted[key] { + var ack json.RawMessage + if err := s.subagentRequest(ctx, "turn/interrupt", TurnInterruptParams{ThreadID: h.ID, TurnID: turn.ID}, &ack); err != nil { + return false, err + } + s.subagents.interrupted[key] = true + } + } + case "completed", "failed": + case "interrupted": + status = "cancelled" + default: + return false, errors.New("codex: unknown child Turn status") + } + created := h.CreatedAt * 1000 + started, completed := nativeMilliseconds(turn.StartedAt), nativeMilliseconds(turn.CompletedAt) + if started != nil { + created = *started + } else if completed != nil { + created = *completed + } + if created <= 0 { + return false, errors.New("codex: child Turn has no native timestamp") + } + payload := proto.SubagentTurnPayload{NativeID: h.ID, TurnID: turn.ID, Status: "in_progress", CreatedAtMS: created, StartedAtMS: started} + key := h.ID + ":" + turn.ID + // A replay never regresses a terminal Turn to active inside this Run. + if _, sent := s.subagents.sent[proto.TypeSubagentTurn+":"+key]; !sent { + if err := s.sendSubagentFact(ctx, proto.TypeSubagentTurn, key, payload); err != nil { + return false, err + } + } + for position, raw := range turn.Items { + itemStatus := status + var identity struct { + ID string `json:"id"` + } + _ = json.Unmarshal(raw, &identity) + if turn.CompletedItems[identity.ID] { + itemStatus = "completed" + } + kind, body, id, err := subagentItemSnapshot(raw, itemStatus) + if kind == proto.TypeSubagentCoordination { + value, decodeErr := subagentCoordination(raw, h.ID) + if decodeErr != nil { + return false, decodeErr + } + for _, target := range value.Recipients { + if !known[target] && value.Status != "failed" { + return false, errors.New("codex: coordination recipient has no verified identity") + } + } + body, err = json.Marshal(value) + } + if err != nil { + return false, err + } + if kind == "" { + continue + } + item := proto.SubagentItemPayload{NativeID: h.ID, TurnID: turn.ID, ItemID: id, Position: int32(position), Kind: kind, Payload: body} + if err := s.sendSubagentFact(ctx, proto.TypeSubagentItem, key+":"+id, item); err != nil { + return false, err + } + } + if status != "in_progress" { + if completed == nil { + return false, errors.New("codex: child completion timestamp not persisted") + } + payload.Status = status + payload.CompletedAtMS = completed + if err := s.sendSubagentFact(ctx, proto.TypeSubagentTurn, key, payload); err != nil { + return false, err + } + } + } + return busy || nativeStatus.Type == "active", nil +} + +func nativeMilliseconds(seconds *int64) *int64 { + if seconds == nil { + return nil + } + ms := *seconds * 1000 + return &ms +} + +func subagentItemSnapshot(raw json.RawMessage, turnStatus string) (string, json.RawMessage, string, error) { + var item struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + Phase string `json:"phase"` + Status string `json:"status"` + } + if json.Unmarshal(raw, &item) != nil { + return "", nil, "", errors.New("codex: invalid child Item") + } + if item.ID == "" { + return "", nil, "", errors.New("codex: child Item has no identity") + } + switch item.Type { + case "agentMessage": + status := "in_progress" + if turnStatus == "completed" { + status = "completed" + } else if turnStatus != "in_progress" { + status = "incomplete" + } + body, err := json.Marshal(proto.OutputMessagePayload{ID: item.ID, Status: status, Text: &item.Text, Phase: item.Phase}) + return proto.TypeOutputMessage, body, item.ID, err + case "commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch": + stage := "after" + if item.Status == "inProgress" { + stage = "before" + } + observation, err := normalizeToolObservation(item.ID, stage, raw) + if err != nil { + return "", nil, "", err + } + body, err := json.Marshal(proto.ToolCallPayload{ID: item.ID, Stage: stage, Observation: observation}) + return proto.TypeToolCall, body, item.ID, err + case "collabAgentToolCall": + return proto.TypeSubagentCoordination, nil, item.ID, nil + case "userMessage": + var source struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &source) != nil { + return "", nil, "", errors.New("codex: invalid child input") + } + content := make([]map[string]string, 0, len(source.Content)) + for _, part := range source.Content { + if part.Type != "text" { + return "", nil, "", errors.New("codex: unsupported child input content") + } + content = append(content, map[string]string{"type": "input_text", "text": part.Text}) + } + body, err := json.Marshal(map[string]any{"input": []any{map[string]any{"type": "message", "role": "user", "content": content}}}) + return "message", body, item.ID, err + case "reasoning": + var source struct { + Summary []string `json:"summary"` + } + if json.Unmarshal(raw, &source) != nil { + return "", nil, "", errors.New("codex: invalid child reasoning") + } + summary := make([]map[string]string, 0, len(source.Summary)) + for _, text := range source.Summary { + summary = append(summary, map[string]string{"type": "summary_text", "text": text}) + } + status := "in_progress" + if turnStatus == "completed" { + status = "completed" + } else if turnStatus != "in_progress" { + status = "incomplete" + } + body, err := json.Marshal(map[string]any{"status": status, "summary": summary}) + return "reasoning", body, item.ID, err + default: + return "", nil, "", errors.New("codex: unsupported child Item observation") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go index b03c839eb..1fd6feabe 100644 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment.go +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment.go @@ -25,34 +25,48 @@ func prepareSystemToolAnchor() error { return nil } -func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { +type nativeToolHook struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` +} + +func (hook nativeToolHook) isRuntimeToolEnvironmentHook() bool { + return hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" +} + +func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { operation, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) if err != nil { - return errors.New("codex: initialized tool hook unavailable") + return nil, errors.New("codex: initialized tool hook unavailable") } var response struct { Data []struct { - CWD string `json:"cwd"` - Hooks []struct { - EventName string `json:"eventName"` - Command string `json:"command"` - Matcher string `json:"matcher"` - Enabled bool `json:"enabled"` - IsManaged bool `json:"isManaged"` - Async bool `json:"async"` - SourcePath string `json:"sourcePath"` - TrustStatus string `json:"trustStatus"` - } `json:"hooks"` + CWD string `json:"cwd"` + Hooks []nativeToolHook `json:"hooks"` Errors []json.RawMessage `json:"errors"` } `json:"data"` } if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { - return errors.New("codex: initialized tool hook configuration unavailable") + return nil, errors.New("codex: initialized tool hook configuration unavailable") + } + return response.Data[0].Hooks, nil +} + +func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { + hooks, err := readNativeToolHooks(ctx, rpc, cwd) + if err != nil { + return err } - for _, hook := range response.Data[0].Hooks { - if hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" { + for _, hook := range hooks { + if hook.isRuntimeToolEnvironmentHook() { return nil } } diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go index b1dbd16b1..376e27ed7 100644 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go @@ -10,7 +10,7 @@ import ( ) func TestInitializedToolHookReadiness(t *testing.T) { - for _, mode := range []string{"ready", "disabled", "unmanaged", "async", "wrong command", "errors"} { + for _, mode := range []string{"ready", "empty", "disabled", "unmanaged", "async", "wrong command", "post hook", "wrong matcher", "wrong source", "untrusted", "errors"} { t.Run(mode, func(t *testing.T) { client, server, cleanup := NewTestClient() defer cleanup() @@ -33,6 +33,16 @@ func TestInitializedToolHookReadiness(t *testing.T) { hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} switch mode { + case "empty": + entry["hooks"] = []any{} + case "post hook": + hook["eventName"] = "postToolUse" + case "wrong matcher": + hook["matcher"] = ".*" + case "wrong source": + hook["sourcePath"] = "/workspace/hooks" + case "untrusted": + hook["trustStatus"] = "untrusted" case "disabled": hook["enabled"] = false case "unmanaged": @@ -50,6 +60,29 @@ func TestInitializedToolHookReadiness(t *testing.T) { if err := <-done; (err == nil) != (mode == "ready") { t.Fatal("hook admission", mode, err) } + go func() { done <- verifySubagentObservationProfile(ctx, client.JSONRPCClient, "/workspace") }() + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { + t.Fatal(err) + } + if mode == "ready" || mode == "empty" { + if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { + t.Fatal(err) + } + if request.Method != "configRequirements/read" { + t.Fatal(request.Method) + } + if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"requirements": map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}}}); err != nil { + t.Fatal(err) + } + } + + if err := <-done; (err == nil) != (mode == "ready" || mode == "empty") { + t.Fatal("subagent hook admission", mode, err) + } + }) } } diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index 5aa6d516a..22e77c1ab 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -13,12 +13,20 @@ func SupportsExecution(version string) bool { } func validateExecutionRequest(req proto.PromptRequestPayload) error { - if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { return fmt.Errorf("mcode: unsupported execution configuration") } if req.ExecutionControls == nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { return fmt.Errorf("mcode: unsupported execution controls") } + if !req.DisableSubagents { + if req.MaxConcurrentSubagents == nil || *req.MaxConcurrentSubagents < 1 { + return fmt.Errorf("mcode: Subagent concurrency limit is required") + } + if _, _, err := subagentReader(); err != nil { + return err + } + } if mode := optionString(req.AgentOptions, "mode"); mode != "" { return fmt.Errorf("mcode: text execution uses default native permissions") } diff --git a/apps/parsar-daemon/internal/agent/mcode/options.go b/apps/parsar-daemon/internal/agent/mcode/options.go index 9ccd9efb1..374f9ea79 100644 --- a/apps/parsar-daemon/internal/agent/mcode/options.go +++ b/apps/parsar-daemon/internal/agent/mcode/options.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -84,6 +85,13 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa } if req.StrictResume { configureTextExecution(config) + if !req.DisableSubagents { + config["agents"] = map[string]any{"default": map[string]any{ + "tools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, + "builtinTools": []string{"task", "task_append", "task_query", "task_output", "task_stop"}, "skills": []string{}, + "features": map[string]bool{"mavis": false, "delegation": true, "webSearch": false}, + }} + } } mode := optionString(opts, "mode") if mode == "" { @@ -102,7 +110,15 @@ func prepareOptionsWithSkills(ctx context.Context, req proto.PromptRequestPayloa } result.Env = append([]string{}, os.Environ()...) if req.StrictResume { - result.Env = executionEnvironment() + result.Env = append(executionEnvironment(), "PARSAR_MCODE_TOOL_POLICY=protected-mcp-v1") + if err := os.WriteFile(filepath.Join(result.DataDir, "mcp.json"), []byte(`{"mcpServers":{}}`), 0o600); err != nil { + return result, err + } + if !req.DisableSubagents { + result.Env = append(result.Env, "PARSAR_MCODE_MAX_SUBAGENTS="+strconv.Itoa(*req.MaxConcurrentSubagents)) + } else { + result.Env = append(result.Env, "PARSAR_MCODE_MAX_SUBAGENTS=0") + } } if raw := opts["env"]; raw != nil && !req.StrictResume { env, ok := raw.(map[string]any) diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation.go b/apps/parsar-daemon/internal/agent/mcode/preparation.go index 74058a5de..fa8339262 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation.go @@ -30,7 +30,7 @@ func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { if ctx == nil { ctx = context.Background() } - if req.RunID != "" || req.Prompt != "" || req.ConversationID != "" || req.ObserveSubagentIdentities { + if req.RunID != "" || req.Prompt != "" || req.ConversationID != "" { return nil, fmt.Errorf("mcode: preparation cannot contain input or product context") } opts, err := prepareWorkspaceOptions(ctx, config, req) @@ -88,16 +88,31 @@ func (p *prepared) Close() error { } p.closed = true p.mu.Unlock() - return p.session.Cancel(context.Background()) + return p.closeUnused(context.Background()) } func (p *prepared) Cancel(ctx context.Context) error { p.mu.Lock() p.closed = true + started := p.binding != nil p.mu.Unlock() + if !started { + return p.closeUnused(ctx) + } return p.session.Cancel(ctx) } +// An unconsumed preparation has no model input or child work to settle. +func (p *prepared) closeUnused(ctx context.Context) error { + p.session.process.Cancel() + select { + case <-p.session.finished: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + func (p *prepared) CancellationOutcome() proto.DonePayload { return p.session.CancellationOutcome() } // The Session goroutine owns preparation, input submission and terminal output. diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go index 3eb1220f5..48732b868 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -121,3 +121,42 @@ func TestPreparedWorkspaceCloseBeforeStart(t *testing.T) { t.Fatal(err) } } + +func TestPreparedSubagentsReleaseUnusedOwner(t *testing.T) { + for _, method := range []string{"close", "cancel"} { + t.Run(method, func(t *testing.T) { + c, r, record := workspaceFixture(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + resource, err := NewPreparationFactory(c)(ctx, r) + if err != nil { + t.Fatal(err) + } + p := resource.(*prepared) + // The fixture only implements preparation. Enable execution cancellation's + // child branch after initialization to verify unused owners never enter it. + p.session.req.DisableSubagents = false + ended := make(chan error, 1) + go func() { + if method == "close" { + ended <- p.Close() + } else { + ended <- p.Cancel(ctx) + } + }() + select { + case err := <-ended: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + p.session.process.Cancel() + t.Fatal("unused owner did not close") + } + raw, err := os.ReadFile(record) + if err != nil || strings.Contains(string(raw), "session/prompt") || strings.Contains(string(raw), "delegation/stop") { + t.Fatalf("unused preparation executed work: %q %v", raw, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index 08d74a625..a1521386e 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -20,29 +20,33 @@ import ( ) type Session struct { - ctx context.Context - req proto.PromptRequestPayload - opts launchOptions - process *clirunner.Process - out chan<- proto.Envelope - frames chan rpcFrame - exited chan struct{} - finished chan struct{} - writeMu sync.Mutex - mu sync.Mutex - sessionID string - permissions map[string]pendingPermission - questions map[string]pendingQuestion - exitErr error - nextID int - responses map[string]chan rpcFrame - steeringReady bool - steeringTurn string - sequence uint64 - active bool - content strings.Builder - tools map[string]toolUpdate - completedTools map[string]bool + ctx context.Context + req proto.PromptRequestPayload + opts launchOptions + process *clirunner.Process + out chan<- proto.Envelope + frames chan rpcFrame + exited chan struct{} + finished chan struct{} + writeMu sync.Mutex + mu sync.Mutex + sessionID string + permissions map[string]pendingPermission + questions map[string]pendingQuestion + exitErr error + nextID int + responses map[string]chan rpcFrame + steeringReady bool + steeringTurn string + sequence uint64 + active bool + content strings.Builder + tools map[string]toolUpdate + completedTools map[string]bool + previousNativeTurns map[string]bool + subagentSettlementError error + rootCompletedAtMS *int64 + subagentHistoryReady bool } var _ agent.Session = (*Session)(nil) @@ -135,9 +139,33 @@ func (s *Session) run(p *prepared) { if p != nil { err = p.awaitStart(err) } + if err == nil { + if s.req.StrictResume && !s.req.DisableSubagents { + var snapshot nativeSubagentSnapshot + snapshot, err = s.readSubagents(s.ctx) + s.subagentHistoryReady = err == nil + s.previousNativeTurns = map[string]bool{} + for _, session := range snapshot.Sessions { + if session.ID == s.sessionID { + for _, turn := range session.Turns { + s.previousNativeTurns[turn.ID] = true + } + } + } + } + } if err == nil { err = s.executePrompt() } + if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady && s.out != nil { + observationErr := s.settleSubagents() + s.mu.Lock() + s.subagentSettlementError = observationErr + s.mu.Unlock() + if observationErr != nil { + err = observationErr + } + } if p != nil || err != nil { s.process.Cancel() <-s.exited @@ -163,12 +191,18 @@ func (s *Session) run(p *prepared) { metadata[proto.DoneMetaAgentSessionID] = sessionID } // ACP context usage is not per-turn token usage; do not record it as spend. - s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata}) + s.emit(proto.TypeDone, proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS}) } func (s *Session) prepareNative() error { var initialized struct { ProtocolVersion int `json:"protocolVersion"` + Meta struct { + Subagents struct { + Version, MaxConcurrent int + WorkspaceTools string + } `json:"parsar/subagents"` + } `json:"_meta"` } if err := s.call("initialize", map[string]any{"protocolVersion": 1, "clientInfo": map[string]string{"name": "parsar", "version": "1"}, "clientCapabilities": map[string]any{"elicitation": map[string]any{"form": map[string]any{}}}}, &initialized, false); err != nil { return err @@ -176,6 +210,9 @@ func (s *Session) prepareNative() error { if initialized.ProtocolVersion != 1 { return fmt.Errorf("mcode: unsupported ACP protocol version %d", initialized.ProtocolVersion) } + if s.req.StrictResume && !s.req.DisableSubagents && (initialized.Meta.Subagents.Version != 1 || initialized.Meta.Subagents.WorkspaceTools != "protected-mcp-v1" || s.req.MaxConcurrentSubagents == nil || initialized.Meta.Subagents.MaxConcurrent != *s.req.MaxConcurrentSubagents) { + return fmt.Errorf("mcode: native Subagent admission is unavailable") + } params := map[string]any{"cwd": s.opts.Dir, "mcpServers": s.opts.MCP} method := "session/new" if s.req.AgentSessionID != "" { @@ -321,6 +358,12 @@ func (s *Session) emit(kind string, payload any) { } func (s *Session) Cancel(ctx context.Context) error { + if s.req.StrictResume && !s.req.DisableSubagents { + if err := s.cancelSubagents(ctx); err != nil { + s.process.Cancel() + return err + } + } s.process.Cancel() if !s.req.StrictResume { return nil diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go new file mode 100644 index 000000000..1469c1ff0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go @@ -0,0 +1,52 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" +) + +func (s *Session) cancelSubagents(ctx context.Context) error { + select { + case <-s.finished: + return nil + default: + } + s.mu.Lock() + native := s.sessionID + s.mu.Unlock() + if native == "" { + return fmt.Errorf("mcode: native child owner is not ready") + } + id, response := s.reserveResponse() + defer s.removeResponse(id) + raw, _ := json.Marshal(map[string]string{"sessionId": native}) + if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/delegation/stop", Params: raw}); err != nil { + return err + } + select { + case frame := <-response: + var result struct { + Receipt *struct { + Failed []string `json:"failedSessionIds"` + } `json:"receipt"` + } + // An abort acknowledgement is followed by durable terminal verification. + if frame.Error != nil || json.Unmarshal(frame.Result, &result) != nil || result.Receipt == nil || len(result.Receipt.Failed) != 0 { + return fmt.Errorf("mcode: child cancellation was not acknowledged") + } + case <-s.exited: + return fmt.Errorf("mcode: native child owner exited during cancellation") + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-s.finished: + s.mu.Lock() + err := s.subagentSettlementError + s.mu.Unlock() + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go b/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go new file mode 100644 index 000000000..0db38b2e0 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/subagent_coordination.go @@ -0,0 +1,52 @@ +package mcode + +import ( + "encoding/json" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func nativeCoordination(session nativeSubagentSession, tool nativeSubagentTool) (*proto.SubagentCoordinationPayload, error) { + if tool.Status != 2 || (tool.Name != "task" && tool.Name != "task_append") { + return nil, nil + } + var args struct { + Prompt, Content string + TaskID string `json:"task_id"` + } + if json.Unmarshal([]byte(tool.Args), &args) != nil { + return nil, fmt.Errorf("mcode: invalid delegation arguments") + } + value := &proto.SubagentCoordinationPayload{ID: tool.ID, ActorID: session.ID, Status: "completed"} + if tool.Name == "task" { + value.Kind, value.Text = "create_subagent_call", &args.Prompt + for _, task := range session.Tasks { + if task.ToolCallID == tool.ID && task.Metadata.ExecutionMode != "append" { + value.Recipients = []string{task.Metadata.ChildSessionID} + break + } + } + } else { + var result struct { + Details struct { + Accepted bool `json:"accepted"` + TaskID string `json:"task_id"` + } `json:"details"` + } + if json.Unmarshal([]byte(tool.Result), &result) != nil || !result.Details.Accepted { + return nil, fmt.Errorf("mcode: delegation receipt is missing") + } + value.Kind, value.Text = "send_subagent_input_call", &args.Content + for _, task := range session.Tasks { + if task.TaskID == result.Details.TaskID { + value.Recipients = []string{task.Metadata.ChildSessionID} + break + } + } + } + if len(value.Recipients) != 1 || value.Recipients[0] == "" { + return nil, fmt.Errorf("mcode: successful delegation lacks child identity") + } + return value, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_messages.go b/apps/parsar-daemon/internal/agent/mcode/subagent_messages.go new file mode 100644 index 000000000..f309dad48 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/subagent_messages.go @@ -0,0 +1,97 @@ +package mcode + +import ( + "encoding/json" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) projectSubagentMessages(session nativeSubagentSession, turn nativeSubagentTurn) error { + position := int32(0) + emit := func(id, kind string, value any) error { + if id == "" { + return fmt.Errorf("mcode: native child Item identity is missing") + } + raw, err := json.Marshal(value) + if err != nil { + return err + } + s.emit(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: session.ID, TurnID: turn.ID, ItemID: id, Position: position, Kind: kind, Payload: raw}) + position++ + return nil + } + for _, message := range session.Messages { + if message.TurnID != turn.ID { + continue + } + if message.Role == "user" { + if err := emit(message.ID, "message", map[string]string{"text": message.Data.Text}); err != nil { + return err + } + continue + } + if message.Role != "assistant" { + continue + } + if message.Data.Thinking != "" { + if err := emit(message.ID+":reasoning", "reasoning", map[string]any{"status": "completed", "summary": []map[string]string{{"type": "summary_text", "text": message.Data.Thinking}}}); err != nil { + return err + } + } + if message.Data.Text != "" { + text := message.Data.Text + if err := emit(message.ID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: message.ID, Status: "completed", Text: &text}); err != nil { + return err + } + } + for _, tool := range message.Data.Tools { + if tool.Status != 2 && tool.Status != 3 { + return fmt.Errorf("mcode: child tool did not settle") + } + coordination, err := nativeCoordination(session, tool) + if err != nil { + return err + } + if coordination != nil { + if err := emit(tool.ID, proto.TypeSubagentCoordination, coordination); err != nil { + return err + } + continue + } + var args map[string]any + var result any + if tool.Args != "" && json.Unmarshal([]byte(tool.Args), &args) != nil { + return fmt.Errorf("mcode: invalid child tool input") + } + if tool.Result != "" && json.Unmarshal([]byte(tool.Result), &result) != nil { + result = tool.Result + } + status := "completed" + if tool.Status == 3 { + status = "failed" + } + update := toolUpdate{ID: tool.ID, Name: tool.Name, Status: status, RawInput: args, RawOutput: result} + observation := workspaceToolObservation(update, "after") + if observation == nil { + var err error + update.mcp, err = s.environmentMCPIdentity(update.Name) + if err != nil { + return err + } + observation, err = environmentMCPObservation(update, "after") + if err != nil { + return err + } + } + // Native task inspection is bookkeeping, not an executed public tool. + if observation == nil { + continue + } + if err := emit(tool.ID, proto.TypeToolCall, proto.ToolCallPayload{ID: tool.ID, Name: tool.Name, Stage: "after", Args: args, Observation: observation}); err != nil { + return err + } + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents.go b/apps/parsar-daemon/internal/agent/mcode/subagents.go new file mode 100644 index 000000000..287d3a20c --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/subagents.go @@ -0,0 +1,219 @@ +package mcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type nativeSubagentSnapshot struct { + Version int `json:"version"` + Root string `json:"rootSessionId"` + Complete bool `json:"complete"` + Sessions []nativeSubagentSession `json:"sessions"` +} +type nativeSubagentSession struct { + ID, Parent, Name, Kind, Purpose, Status string + CreatedAt int64 + Turns []nativeSubagentTurn + Messages []nativeSubagentMessage + Tasks []nativeSubagentTask +} +type nativeSubagentTurn struct { + ID, Status string + CreatedAt int64 + CompletedAt *int64 +} +type nativeSubagentMessage struct { + ID, TurnID, Role string + CreatedAt int64 + Data struct { + Text string `json:"msg_content"` + Thinking string `json:"thinking_content"` + Tools []nativeSubagentTool `json:"tool_calls"` + } +} +type nativeSubagentTool struct { + ID string `json:"tool_call_id"` + Name string `json:"tool_name"` + Status int `json:"tool_call_status"` + Args string `json:"tool_call_args"` + Result string `json:"tool_call_result_data"` +} +type nativeSubagentTask struct { + TaskID, ToolCallID, OwnerSessionID, Status string + Metadata struct{ ChildSessionID, ParentSessionID, ParentTurnID, SubTurnID, ExecutionMode string } +} + +func subagentReader() (string, string, error) { + node, bridge := os.Getenv("PARSAR_MCODE_NODE"), os.Getenv("PARSAR_MCODE_WORKSPACE_BRIDGE") + reader := filepath.Join(filepath.Dir(bridge), "subagent-snapshot.mjs") + for _, path := range []string{node, bridge, reader} { + resolved, err := filepath.EvalSymlinks(path) + if err != nil || !filepath.IsAbs(path) || resolved != path { + return "", "", fmt.Errorf("mcode: protected Subagent reader is unavailable") + } + } + return node, reader, nil +} + +func (s *Session) readSubagents(ctx context.Context) (nativeSubagentSnapshot, error) { + var snapshot nativeSubagentSnapshot + node, reader, err := subagentReader() + if err != nil { + return snapshot, err + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, node, "--disable-warning=ExperimentalWarning", reader, s.opts.DataDir, s.sessionID) + command.Env = executionEnvironment() + stdout, err := command.StdoutPipe() + if err != nil { + return snapshot, fmt.Errorf("mcode: child history reader is unavailable") + } + if err := command.Start(); err != nil { + return snapshot, fmt.Errorf("mcode: child history reader is unavailable") + } + raw, readErr := io.ReadAll(io.LimitReader(stdout, 64*1024*1024+1)) + if readErr != nil || len(raw) > 64*1024*1024 { + _ = command.Process.Kill() + } + err = command.Wait() + if err != nil || readErr != nil || len(raw) > 64*1024*1024 { + return snapshot, fmt.Errorf("mcode: complete child history is unavailable") + } + if json.Unmarshal(raw, &snapshot) != nil || snapshot.Version != 1 || !snapshot.Complete || snapshot.Root != s.sessionID { + return snapshot, fmt.Errorf("mcode: invalid child history snapshot") + } + return snapshot, nil +} + +func (s *Session) settleSubagents() error { + if s.req.DisableSubagents { + return nil + } + ctx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) + defer cancel() + for { + snapshot, err := s.readSubagents(ctx) + if err != nil { + return err + } + settled := true + for _, session := range snapshot.Sessions { + if session.ID == snapshot.Root && s.rootCompletedAtMS == nil { + for _, turn := range session.Turns { + if !s.previousNativeTurns[turn.ID] && turn.Status == "completed" && turn.CompletedAt != nil { + s.rootCompletedAtMS = turn.CompletedAt + break + } + } + } + for _, task := range session.Tasks { + if task.Status == "queued" || task.Status == "running" || task.Status == "stopping" { + settled = false + } + } + if session.ID != snapshot.Root { + for _, turn := range session.Turns { + if turn.Status == "accepted" { + settled = false + } + } + } + } + if settled { + return s.projectSubagents(snapshot) + } + // Root output is frozen. Drain native notifications while its existing + // background task owners finish; none becomes a root output delta. + select { + case <-ctx.Done(): + return fmt.Errorf("mcode: child settlement did not complete") + case _, ok := <-s.frames: + if !ok { + return fmt.Errorf("mcode: native owner ended before child settlement") + } + case <-time.After(200 * time.Millisecond): + } + } +} + +func (s *Session) projectSubagents(snapshot nativeSubagentSnapshot) error { + byID := map[string]nativeSubagentSession{} + tasks := map[string]nativeSubagentTask{} + for _, session := range snapshot.Sessions { + if session.ID == "" || byID[session.ID].ID != "" { + return fmt.Errorf("mcode: duplicate native child identity") + } + byID[session.ID] = session + for _, task := range session.Tasks { + if task.Metadata.ExecutionMode != "append" && task.Metadata.ChildSessionID != "" { + tasks[task.Metadata.ChildSessionID] = task + } + } + } + emitted := map[string]bool{snapshot.Root: true} + for len(emitted) < len(byID) { + progress := false + for _, session := range snapshot.Sessions { + if emitted[session.ID] || !emitted[session.Parent] { + continue + } + task, ok := tasks[session.ID] + if !ok || session.Kind != "task" || session.CreatedAt <= 0 || task.OwnerSessionID != session.Parent || task.Metadata.ParentTurnID == "" || task.ToolCallID == "" { + return fmt.Errorf("mcode: native child provenance is incomplete") + } + name := session.Name + s.emit(proto.TypeSubagentIdentity, proto.SubagentIdentityPayload{NativeID: session.ID, ParentNativeID: session.Parent, NativeCreatedAt: session.CreatedAt / 1000, ParentTurnID: task.Metadata.ParentTurnID, SourceItemID: task.ToolCallID, Name: &name}) + emitted[session.ID], progress = true, true + } + if !progress { + return fmt.Errorf("mcode: native child graph is incomplete") + } + } + for _, session := range snapshot.Sessions { + if session.ID == snapshot.Root { + for _, message := range session.Messages { + if s.previousNativeTurns[message.TurnID] { + continue + } + for _, tool := range message.Data.Tools { + value, err := nativeCoordination(session, tool) + if err != nil { + return err + } + if value != nil { + value.ActorID = "" + s.emit(proto.TypeSubagentCoordination, value) + } + } + } + continue + } + for _, turn := range session.Turns { + if turn.ID == "" || turn.CreatedAt <= 0 { + return fmt.Errorf("mcode: invalid child Turn") + } + status := map[string]string{"accepted": "in_progress", "completed": "completed", "failed": "failed", "aborted": "cancelled"}[turn.Status] + if status == "" || (status != "in_progress" && turn.CompletedAt == nil) { + return fmt.Errorf("mcode: incomplete child Turn boundary") + } + value := proto.SubagentTurnPayload{NativeID: session.ID, TurnID: turn.ID, Status: "in_progress", CreatedAtMS: turn.CreatedAt} + s.emit(proto.TypeSubagentTurn, value) + if err := s.projectSubagentMessages(session, turn); err != nil { + return err + } + value.Status, value.CompletedAtMS = status, turn.CompletedAt + s.emit(proto.TypeSubagentTurn, value) + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go new file mode 100644 index 000000000..de4b4b414 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go @@ -0,0 +1,67 @@ +package mcode + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func childSnapshot(t *testing.T) nativeSubagentSnapshot { + t.Helper() + var snapshot nativeSubagentSnapshot + const raw = `{"version":1,"complete":true,"rootSessionId":"root","sessions":[ + {"id":"root","turns":[{"id":"parent-turn","status":"completed","createdAt":1000,"completedAt":2000}],"tasks":[{"taskId":"task","ownerSessionId":"root","toolCallId":"spawn","status":"succeeded","metadata":{"childSessionId":"child","parentTurnId":"parent-turn","executionMode":"foreground"}}]}, + {"id":"child","parent":"root","kind":"task","name":"worker","createdAt":1100,"turns":[{"id":"turn","status":"completed","createdAt":1200,"completedAt":1800}],"messages":[{"id":"input","turnId":"turn","role":"user","data":{"msg_content":"child input"}},{"id":"answer","turnId":"turn","role":"assistant","data":{"msg_content":"child output","thinking_content":"native reasoning"}}]}]}` + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { + t.Fatal(err) + } + return snapshot +} + +func TestSubagentSnapshotsKeepOwnHistoryAndStableNativeIdentity(t *testing.T) { + project := func(snapshot nativeSubagentSnapshot) []proto.Envelope { + out := make(chan proto.Envelope, 32) + s := &Session{ctx: context.Background(), out: out, previousNativeTurns: map[string]bool{}} + if err := s.projectSubagents(snapshot); err != nil { + t.Fatal(err) + } + close(out) + var events []proto.Envelope + for event := range out { + events = append(events, event) + } + return events + } + first := project(childSnapshot(t)) + second := project(childSnapshot(t)) + if len(first) != 6 { + t.Fatalf("got %d events", len(first)) + } + for i, event := range first { + if event.Type != second[i].Type || !reflect.DeepEqual(event.Payload, second[i].Payload) { + t.Fatal("cold snapshot identities changed") + } + } + if first[0].Type != proto.TypeSubagentIdentity || first[1].Type != proto.TypeSubagentTurn || first[5].Type != proto.TypeSubagentTurn { + t.Fatal("identity/turn/item ordering changed") + } + var input proto.SubagentItemPayload + if err := json.Unmarshal(first[2].Payload, &input); err != nil { + t.Fatal(err) + } + if input.NativeID != "child" || input.TurnID != "turn" || input.ItemID != "input" || input.Kind != "message" { + t.Fatalf("invalid child ownership: %+v", input) + } +} + +func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { + snapshot := childSnapshot(t) + snapshot.Sessions[0].Tasks = nil + s := &Session{ctx: context.Background(), out: make(chan proto.Envelope, 32)} + if err := s.projectSubagents(snapshot); err == nil { + t.Fatal("accepted child without original spawning provenance") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index b558380ba..53753b2df 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -125,5 +125,21 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P } opts.MCP = []map[string]any{{"name": "parsar_workspace", "command": c.Node, "args": []string{c.Bridge, path}, "env": []map[string]string{}}} opts.MCP = append(opts.MCP, servers...) + if !req.DisableSubagents { + // This native data directory belongs to one public Session and its + // descendants. ACP's ephemeral server map otherwise covers only root. + configured := map[string]any{} + for _, server := range opts.MCP[:1] { + name, _ := server["name"].(string) + configured[name] = map[string]any{"type": "stdio", "command": server["command"], "args": server["args"], "env": map[string]string{}, "enabled": true} + } + raw, err := json.Marshal(map[string]any{"mcpServers": configured}) + if err != nil { + return opts, err + } + if err := os.WriteFile(filepath.Join(opts.DataDir, "mcp.json"), raw, 0o600); err != nil { + return opts, err + } + } return opts, nil } diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index bbb2f4310..f8f4b7427 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -99,6 +99,7 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a TextVerbosity: codex.SupportsTextVerbosity, ExecutionControls: codex.SupportsTextVerbosity, SubagentControl: true, + SubagentObservations: true, }, }, Pi: proto.SupportedAgentKind{ diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index ba5108250..ad3f0a232 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -97,6 +97,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } out.Info.Available, out.Info.Version = true, info.SDK + out.Info.Capabilities.SubagentObservations = info.SupportsSubagents() out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() out.Info.Capabilities.MCPHTTPBearerAuth = info.SupportsHTTPMCPBearer() out.Info.Capabilities.MCPHTTPRequired = info.SupportsHTTPMCPRequired() diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index b238280f1..5f16b35fc 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -124,7 +124,7 @@ func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { } } -func TestClaudeSDKMCPFeatureDiscovery(t *testing.T) { +func TestClaudeSDKFeatureDiscovery(t *testing.T) { root := t.TempDir() t.Setenv("PARSAR_HOME", root) t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "main.js")) @@ -133,7 +133,7 @@ func TestClaudeSDKMCPFeatureDiscovery(t *testing.T) { t.Fatal(err) } t.Setenv(claudeSDKNodeEnv, node) - for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}} { + for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}} { out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} return info, nil @@ -142,5 +142,8 @@ func TestClaudeSDKMCPFeatureDiscovery(t *testing.T) { if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools != supported || out.Info.Capabilities.MCPHTTPBearerAuth != (supported && slices.Contains(features, "mcp_http_bearer_auth")) || out.Info.Capabilities.MCPHTTPRequired != (supported && slices.Contains(features, "mcp_http_required")) { t.Fatal("MCP feature discovery widened the runtime profile") } + if out.Info.Capabilities.SubagentObservations != slices.Contains(features, "subagent_resources") { + t.Fatal("Subagent feature discovery does not match the runtime contract") + } } } diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index 13a0b7bbe..c4a7fdcfd 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -28,6 +28,8 @@ func discoverMCode(rc *runContext, check func(context.Context, string) (string, result.Capabilities.ExecutionControls = true result.Capabilities.ToolObservations = true result.Capabilities.SubagentControl = true + // Native preparation verifies the applied admission/tool profile before input. + result.Capabilities.SubagentObservations = true result.Capabilities.EnvironmentNone = true } fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/parsar-daemon/internal/cli/mcode_execution_test.go index d4197ad4b..074cec7ab 100644 --- a/apps/parsar-daemon/internal/cli/mcode_execution_test.go +++ b/apps/parsar-daemon/internal/cli/mcode_execution_test.go @@ -15,7 +15,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { t.Setenv("PARSAR_MCODE_AGENTS_API", tc.enabled) rc := &runContext{stdout: io.Discard, stderr: io.Discard} info := discoverMCode(rc, func(context.Context, string) (string, error) { return tc.version, nil }) - if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified { + if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified || info.Capabilities.SubagentObservations != tc.qualified { t.Fatalf("capabilities=%+v", info.Capabilities) } if info.Capabilities.NativeSessionRecovery || info.Capabilities.LocalEnvironment || info.Capabilities.FunctionTools { diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 92947cde0..3ec1d8241 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -64,8 +64,8 @@ business Team orchestration are separate from protocol coverage. This inventory is based on the pinned Python source, not our generated OpenAPI. It contains 42 distinct HTTP operations in 15 resource classes, excluding async -duplicates, overloads and client-side helpers. There are 36 handler entries; the six -Subagent read operations remain missing. The separate +duplicates, overloads and client-side helpers. There are 42 handler entries; the six +[Subagent reads](subagents.md) have three-harness Docker workflow qualification. The separate general `/v1/files` source-file API and `/v1/skills` resource/version operations are outside this 42-operation count. @@ -88,10 +88,10 @@ paths start at `/vaults`, not `/agents/vaults`. | sessions.turns | retrieve, list | Implemented reads; lifecycle conformance still partial | | sessions.items | list | Partial Item variants | | sessions.artifacts | retrieve, list, delete, content | Shared output capture and immutable stored reads/deletion on accepted Docker profiles and [qualified user-managed workflows](user-managed-runtime-v1.md) (prior Core-managed E2B evidence remains historical), including retained downloads after Runtime loss; exact upstream defaults/errors, unchanged-file republishing and cancellation-edge parity remain unverified | -| sessions.subagents | retrieve, list | Missing | -| sessions.subagents.items | list | Missing | -| sessions.subagents.turns | retrieve, list | Missing | -| sessions.subagents.turns.items | list | Missing | +| sessions.subagents | retrieve, list | [Three-harness Docker reads, native lifecycle limits and real evidence](subagents.md); full multi-agent semantics remain partial | +| sessions.subagents.items | list | Qualified own-child history reads; full Item variants and live child streaming remain partial | +| sessions.subagents.turns | retrieve, list | Implemented; shared Session/child IDs | +| sessions.subagents.turns.items | list | Implemented; scoped persisted reads | | environments | retrieve | Three-harness colocated self-hosted implementation and qualified Docker hosted profiles: durable status and safe initial-file metadata; other installation inventory and full lifecycle parity remain gaps | | environments.files | create, list | [Bounded live listing and inline/source-file creation](environment-files.md) on qualified Docker workspaces; [user-managed enrollment](user-managed-runtime-v1.md) reuses the local implementation with separate real public acceptance. Full listing, overwrite and error semantics remain partial | | environments.templates | create, retrieve, update, list, delete | [Reusable network, files, env/setup/packages, inline/referenced Skills and Session snapshots](environment-templates.md); other initialization and full semantics remain gaps | @@ -162,7 +162,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | -| Subagents / multi_agent | Six public child read operations, enabled execution, child lifecycle/interactions and full recovery; deferred outside the MVP | +| Subagents / multi_agent | Six reads and same-child recovery have three-harness Docker evidence; optional native operations, live child progress, full lifecycle/interactions and tool combinations remain explicit gaps | | Environment Templates | Unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions, structured output and reasoning/verbosity combinations | | Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | @@ -315,8 +315,9 @@ including further deployment qualification; this inventory describes merged beha replace the entire field ([configuration guide](https://developers.openai.com/api/docs/guides/agents-api/configuration)). Tools null clears the list as specified by pinned `session_create_params.py`. Saved metadata never becomes Session metadata. A model name is not a daemon engine name. - Current admission requires disabled multi-agent, implicit reasoning, tier `auto`, - ordinary text and non-deferred functions. Unsupported saved settings fail before + Current admission uses the qualified harness profile for multi-agent execution, + implicit reasoning, tier `auto`, ordinary text and non-deferred functions. + Enabled multi-agent/function combinations remain unsupported. Unsupported saved settings fail before Session persistence, unless replaced by supported overrides. Other native options remain implementation gaps, not excluded protocol variants. Fixed SDK/raw HTTP checks cover inherited/overridden configuration, tenant ownership, @@ -395,8 +396,9 @@ and cancellation. This does not close the remaining protocol/transport gaps. existing Sessions retain their immutable choice. `AGENTS_API_HARNESSES` explicitly adds installed deployment profiles without requiring a managed Provider. Model identity is independent. -All three profiles require disabled `multi_agent`, implicit reasoning, service tier -`auto` and ordinary text output. Optional tools/configuration are qualified per +All three profiles require implicit reasoning, service tier `auto` and ordinary +text output. Enabled `multi_agent` qualification is tracked separately in +[Subagents](subagents.md); other profiles continue to reject unsupported execution. Optional tools/configuration are qualified per operation and placement; native support is not public admission by itself. | Engine | Qualified placements and limits | @@ -464,18 +466,12 @@ on fresh and resumed Turns. Operator feature preferences cannot re-enable them. Controlled model-boundary tests check absence of direct/deferred subagent tools while the official function workflow continues to run. -Disabled `multi_agent` input is admitted. Enabled multi-agent execution and public -Subagent resources are still unsupported. Do not infer that `Agent.tools` is the -complete native tool registry: environment and subagent tools have separate -configuration. The upstream behavior of internal Goal, Skills and user-input -utilities needs further evidence; their presence alone is not proof of a mismatch. - -A private typed discovery path projects verified native child identities through -the leased execution journal. It freezes service identity, Session ownership, -native parent/creation and first-observation provenance for internal scoped reads. -It does not enable the public resources above or infer lifecycle from idle/unload. -Bounded discovery and delivery rules are documented in -[the contributor guide](../../CONTRIBUTING.md#current-implementation). +Disabled `multi_agent` continues to remove native child tools. The six public reads +and their neutral identity/lifecycle/Turn/Item observations are described in +[Subagents](subagents.md); native qualification is tracked there. The service does +not infer closure from idle/unload, and public capability declarations alone do +not qualify an adapter. Environment and child tools have separate configuration; +`Agent.tools` is not assumed to enumerate every native utility. ### Turn recovery reads @@ -510,8 +506,9 @@ prepared by release `906069e` before upgrade; see the [upgrade procedure](../../services/agents-api/README.md#upgrading-archived-item-history). The retired archive format could not recover unrecorded message boundaries or outcomes; those limitations remain in already indexed historical Items. -Unsupported native variants, reasoning, subagent Items and Items mutation -are not covered. Public submission supports text messages, cancellation and function results. +Other native variants, full reasoning coverage and Items mutation remain gaps. +Qualified child Items are described in [Subagents](subagents.md). Public submission +supports text messages, cancellation and function results. Legacy Done frames alone do not complete assistant Items. Aggregate answer text is confirmed by successful Turn termination; failed Turns retain observed deltas diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index f820e2345..e65df5099 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -145,3 +145,13 @@ The requested MiniMax Code workspace Runtime is qualified separately in workspace execution and shared Files/Artifacts, cancellation/recovery and independent Docker deployment. Text-only qualification is an intermediate milestone for that scope, not completion of the requested Runtime integration. + +## Optional Subagent observations + +A harness that supports the Subagent resource reads implements the existing +[neutral observation contract](subagents.md#common-adapter-contract). It reports +verified child identity, lifecycle effects and owned Turn/Item history through +the authenticated Run, then qualifies those facts with real execution. It does +not add routes, storage branches or a harness-specific Core scheduler. Report +unsupported native facts explicitly; completing a child task is not closing its +Subagent. Native background work must remain owned through settlement and cancel. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 0e9c0ddf1..4070ca2c8 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -55,6 +55,20 @@ definitions: - text - tools type: object + v1.AgentContent: + properties: + encrypted_content: + type: string + text: + type: string + type: + enum: + - output_text + - encrypted_content + type: string + required: + - type + type: object v1.AgentDeleted: properties: deleted: @@ -681,6 +695,8 @@ definitions: properties: action: $ref: '#/definitions/v1.WebSearchAction' + agent_id: + type: string arguments: {} call_id: type: string @@ -699,6 +715,8 @@ definitions: type: integer id: type: string + model: + type: string name: type: string output: {} @@ -707,11 +725,21 @@ definitions: - commentary - final_answer type: string + reasoning_effort: + type: string + recipient_agent_id: + type: string + recipient_agent_ids: + items: + type: string + type: array role: enum: - user - assistant type: string + sender_agent_id: + type: string server_label: type: string status: @@ -721,6 +749,10 @@ definitions: - failed - incomplete type: string + summary: + items: + $ref: '#/definitions/v1.SummaryText' + type: array turn_id: type: string type: @@ -731,15 +763,24 @@ definitions: - function_call - function_call_output - web_search_call + - reasoning + - agent_message + - create_subagent_call + - send_subagent_input_call + - resume_subagent_call + - wait_for_subagents_call + - interrupt_subagent_call + - close_subagent_call type: string required: - id - - status - turn_id - type type: object v1.ItemContent: properties: + encrypted_content: + type: string image_url: type: string text: @@ -749,6 +790,7 @@ definitions: - input_text - output_text - input_image + - encrypted_content type: string required: - type @@ -1157,6 +1199,8 @@ definitions: $ref: '#/definitions/v1.Session' session_id: type: string + subagent: + $ref: '#/definitions/v1.Subagent' text: type: string turn: @@ -1476,6 +1520,68 @@ definitions: type: type: string type: object + v1.Subagent: + properties: + closed_at: + type: integer + x-nullable: true + id: + type: string + instructions: + items: + $ref: '#/definitions/v1.AgentContent' + type: array + x-nullable: true + name: + type: string + x-nullable: true + object: + enum: + - agent.session.subagent + type: string + opened_at: + type: integer + parent_agent_id: + type: string + session_id: + type: string + status: + enum: + - active + - closed + type: string + required: + - id + - object + - opened_at + - parent_agent_id + - session_id + - status + type: object + v1.SubagentList: + properties: + data: + items: + $ref: '#/definitions/v1.Subagent' + type: array + has_more: + type: boolean + required: + - data + - has_more + type: object + v1.SummaryText: + properties: + text: + type: string + type: + enum: + - summary_text + type: string + required: + - text + - type + type: object v1.TextConfig: properties: format: @@ -1551,6 +1657,9 @@ definitions: - failed - cancelled type: string + subagent_id: + type: string + x-nullable: true usage: allOf: - $ref: '#/definitions/v1.TokenUsage' @@ -1567,6 +1676,21 @@ definitions: properties: code: enum: + - context_length_exceeded + - session_budget_exceeded + - usage_limit_exceeded + - rate_limit_exceeded + - server_overloaded + - cyber_policy + - connection_failed + - server_error + - authentication_error + - invalid_request + - resource_not_found + - sandbox_error + - executor_version_incompatible + - active_turn_not_steerable + - request_timeout - internal_error type: string message: @@ -2523,31 +2647,31 @@ paths: - application/json description: Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, - text verbosity, non-deferred function tools, disabled multi_agent, implicit - reasoning, service tier auto and environment type none, subject to the configured - engine. Codex additionally supports HTTP MCP with explicit service origin, - native allowed_tools and boolean required defaulting to false. Session vault_ids - attach only project-owned Vaults; credential_id selects an attached static - bearer credential for the exact HTTPS URL, while null/omission selects a unique - match or remains anonymous. Ambiguous selection rejects creation. Frozen private - selections never populate an omitted public credential_id; missing decryption - configuration fails dispatch without anonymous fallback. Required initialization - uses native startup before the first native Turn, including cold resume, and - requires a separately advertised capability; exact hosted creation timing - and error parity remain unverified. Other MCP origins and OAuth remain unsupported. - The self_hosted profile requires Codex, an absolute workspace_directory and - empty capability_directories, with optional non-deferred function tools and - HTTP MCP using explicit service origin, optionally authenticated by the attached - Vault rules. Remote MCP and remote Bearer authentication each require separately - advertised combination support; old peers cannot receive unsupported work. - Omitted/null capability_directories use the empty-list default; self_hosted - requires configured execution plus executor registry. Claude SDK currently - requires medium verbosity and object-root function schemas. It supports anonymous - or attached static-bearer service-origin HTTP MCP on none with boolean required - and separately advertised MCP/bearer/required runtime support. Required servers - must be connected before the first native input is released; pending or failed - startup rejects execution. The shared Vault selection and immutable binding - rules apply; unsupported native labels/tool names reject before persistence. + text verbosity, non-deferred function tools, adapter-qualified multi_agent + with persisted Subagent reads, implicit reasoning, service tier auto and environment + type none, subject to the configured engine. Codex additionally supports HTTP + MCP with explicit service origin, native allowed_tools and boolean required + defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id + selects an attached static bearer credential for the exact HTTPS URL, while + null/omission selects a unique match or remains anonymous. Ambiguous selection + rejects creation. Frozen private selections never populate an omitted public + credential_id; missing decryption configuration fails dispatch without anonymous + fallback. Required initialization uses native startup before the first native + Turn, including cold resume, and requires a separately advertised capability; + exact hosted creation timing and error parity remain unverified. Other MCP + origins and OAuth remain unsupported. The self_hosted profile requires Codex, + an absolute workspace_directory and empty capability_directories, with optional + non-deferred function tools and HTTP MCP using explicit service origin, optionally + authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication + each require separately advertised combination support; old peers cannot receive + unsupported work. Omitted/null capability_directories use the empty-list default; + self_hosted requires configured execution plus executor registry. Claude SDK + currently requires medium verbosity and object-root function schemas. It supports + anonymous or attached static-bearer service-origin HTTP MCP on none with boolean + required and separately advertised MCP/bearer/required runtime support. Required + servers must be connected before the first native input is released; pending + or failed startup rejects execution. The shared Vault selection and immutable + binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata @@ -3219,6 +3343,401 @@ paths: summary: List persisted execution Items tags: - Items + /agents/sessions/{session_id}/subagents: + get: + description: Includes nested and closed Subagents. Cursors belong to the same + tenant and Session. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Last Subagent ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Resource order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SubagentList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List Session Subagents + tags: + - Subagents + /agents/sessions/{session_id}/subagents/{subagent_id}: + get: + description: Returns this Session's persisted Subagent. Active includes idle + between Turns. Resuming preserves opened_at and clears closed_at. Unknown + or inaccessible parent scopes return not found. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Subagent ID + in: path + name: subagent_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Subagent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve a Session Subagent + tags: + - Subagents + /agents/sessions/{session_id}/subagents/{subagent_id}/items: + get: + description: Returns only this Subagent's own Items across all its Turns, not + its descendants' Items. Cursors belong to the same tenant, Session and Subagent. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Subagent ID + in: path + name: subagent_id + required: true + type: string + - description: Last Item ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Resource order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.ItemList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List a Subagent's Items + tags: + - Subagents + /agents/sessions/{session_id}/subagents/{subagent_id}/turns: + get: + description: Includes this Subagent's Turns after resume. Cursors belong to + the same tenant, Session and Subagent. Missing recorded usage remains null. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Subagent ID + in: path + name: subagent_id + required: true + type: string + - description: Last Turn ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Creation order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.TurnList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List a Subagent's Turns + tags: + - Subagents + /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}: + get: + description: Returns the same canonical Turn exposed by the Session Turn endpoint, + scoped to the owning Subagent. Unknown or inaccessible parent scopes return + not found. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Subagent ID + in: path + name: subagent_id + required: true + type: string + - description: Turn ID + in: path + name: turn_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.Turn' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve a Subagent Turn + tags: + - Subagents + /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items: + get: + description: Returns Items owned by this exact Subagent Turn. Cursors belong + to the same tenant, Session, Subagent and Turn. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + - description: Subagent ID + in: path + name: subagent_id + required: true + type: string + - description: Turn ID + in: path + name: turn_id + required: true + type: string + - description: Last Item ID from the previous page + in: query + name: after + type: string + - default: 20 + description: Page size + in: query + maximum: 100 + minimum: 1 + name: limit + type: integer + - default: desc + description: Resource order + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.ItemList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: List a Subagent Turn's Items + tags: + - Subagents /agents/sessions/{session_id}/turns: get: description: Returns persisted state in creation order. The cursor belongs to diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md new file mode 100644 index 000000000..91ed24a8d --- /dev/null +++ b/contracts/agents-api/subagents.md @@ -0,0 +1,149 @@ +# Subagent resources and Runtime observations + +The target is the six read operations in the pinned SDK in `upstream.json`. +Implementation and qualification are separate: the public types and handlers do +not qualify a harness merely because it can deserialize them. The Docker V1 +workflows below have real execution evidence; they do not establish complete +multi-agent or protocol compatibility. + +## Public reads + +All paths are under `/v1/agents/sessions/{session_id}` and require the same tenant +authentication and `OpenAI-Beta: agents=v1` as ordinary Session reads. + +| Path | Result | +| --- | --- | +| `/subagents` | Direct, nested and closed Subagents | +| `/subagents/{subagent_id}` | One Subagent | +| `/subagents/{subagent_id}/items` | Only that child's own Items | +| `/subagents/{subagent_id}/turns` | Only that child's own Turns | +| `/subagents/{subagent_id}/turns/{turn_id}` | One owned Turn | +| `/subagents/{subagent_id}/turns/{turn_id}/items` | Only that child's Items in that Turn | + +Lists use `after`, `limit` (1–100, default 20) and `order` (default `desc`). +Cursors must belong to the requested tenant, Session, child and optional Turn. +Child Turns also appear in Session Turn reads with the same IDs. Their `agent_id` +and `subagent_id` identify the child. Root Turns have `subagent_id: null`. Session +Items remain root-owned; inherited native parent transcripts are not child work. + +Active includes idle. Successful close records native time; successful reopen +preserves identity and `opened_at`, clears `closed_at`, and emits `active` once. +An already-active resume is a no-op. Turn completion, interruption and process +release do not close a Subagent. Unknown token measurements remain null. + +## Common adapter contract + +Use `internal/agentdaemon/proto/subagents.go` through the existing authenticated +Run and execution journal. No new transport, scheduler or model/tool loop exists. + +| Fact | Adapter obligation | +| --- | --- | +| Identity | Prove native ID, original parent and creation time; publish parents first | +| Lifecycle effect | Prove a successful close/reopen and its original time; stable effect identity across history reads | +| Child Turn | Supply native-owned ID, state and source timestamps, with known Usage only; a missing native cancellation timestamp requires a durable confirmed-effect receipt | +| Child Item | Supply an ordered complete message/tool snapshot using the existing neutral vocabulary | +| Coordination | Translate the native operation and actor/recipient identities without putting native tool names in Core | + +Core supplies public IDs and ownership from the authorized Session binding. +Identity, lifecycle, child history and live event projections commit atomically +under the existing Session lock and execution lease. Repeated observations retain +IDs and do not duplicate lifecycle events. Conflicting effects fail. A failed +coordination request to a nonexistent child preserves its opaque requested target; +it does not create a Subagent or imply that the target exists. + +Child Turns have a native writer, so their storage is separate from the Core work +queue. A read-only SQL view joins root and child Turns for public pagination. +Child work never becomes a second queued Core execution. Public GETs read durable +resources; they neither start native processes nor replay execution. + +An adapter freezes root output before child settlement, keeps the existing native +owner/reader alive while finite child work completes, and delivers child Items +before their terminal Turn snapshot and Run completion. Cancellation uses the +same owner and must settle child writes before release. A failed observation or +uncertain native effect cannot be converted to a successful empty history. +Codex cancellation continues under the same owner after a caller deadline; a +later call can confirm settlement without repeating the native interrupt. Actual +observation failures remain fail-closed, independently of local process cleanup. + +## Native evidence and remaining qualification + +Fixed Codex 0.153.4 real probes established original close/resume/no-op/failure +facts by correlating direct-tool output with the same call's persisted canonical +completion. Cold resume has no raw-result opt-in, so this requires native persisted +receipts. The proven profile excludes result-rewriting hooks, code-mode and +plugins. A packaged immutable Bash PreToolUse hook is allowed only with enforced +managed-only hook discovery and its exact Runtime command and matcher. Native Turn times have second precision; converting to milliseconds does +not create additional precision. A real root-first probe confirmed child file +work can finish in the same owner after the root finishes. + +Multi-agent execution with required ToolEnvironment initialization remains an +explicit combination gap until child hook-process failures are handled by the +same execution owner. The managed PreToolUse hook itself does not alter lifecycle +result receipts, but existing hook-failure handling only covers the root Turn. Ordinary single-agent environment/package +execution is unchanged. Public function and MCP tools with enabled multi-agent execution +remain unqualified. These limitations do not redefine the official protocol. + +Claude's fixed SDK uses native Agent and idle-child SendMessage calls. Original +private child records establish parentage, the first own input time and subsequent +own Turns; inherited parent context is excluded. The existing query owner admits +children before start and retains their history through settlement. Confirmed +cancellation uses a protected immutable effect receipt because native abort can +leave no terminal record. The receipt preserves the confirmed effect time across +reads without rewriting native history. This profile has no qualified close +operation, and completed or cancelled children remain active. See the +[adapter contract](../../packages/claude-sdk-adapter/SUBAGENTS.md) for restrictions. + +MiniMax's fixed ACP supplies native delegation operations. Its Session-private +SQLite records supply original child identity, accepted inputs, terminal times +and own messages. The native task-create transaction enforces the requested +concurrent limit before start; native preparation must acknowledge that applied +limit and the protected tool profile before any model input. Discovery describes +adapter support, not proof that an arbitrary installed CLI applied these controls. +Neither adapter may substitute parent output, task completion, observation time +or an empty list for missing facts. + +## Qualified Docker workflows + +The three harnesses passed the same six GET checks with Python SDK 3.13.0 and +raw HTTP against the independent Core, dedicated PostgreSQL and colocated Runtime. +Checks include two real children with their own model output, ascending/descending +pagination, scoped cursors, root/child Item separation, Session/child Turn identity +and cross-project denial. A new native process continued the same child without +changing old IDs, timestamps or history. Public cancellation stopped actual child +workspace writes, persisted cancelled Turns and left the Subagent active. Core +restart preserved all previously captured resources byte-for-byte after JSON +normalization. + +| Harness | Native execution | Verified optional behavior | Explicit limits | +| --- | --- | --- | --- | +| Codex 0.153.4 | Native app-server, Kimi K3 Responses | Nested children, successful close and reopen, same-child continuation | Required ToolEnvironment and public function/MCP combinations are not qualified | +| Claude Agent SDK 0.3.269 | Native Agent/SendMessage, Kimi K3 Anthropic endpoint | Foreground `parsar_worker`, idle-child continuation, protected Bash | No qualified close; running-child messages, background work, alternate child profiles and per-call model overrides are rejected | +| MiniMax Code 0.4.12 | Fixed source `33b259bbbeb1c16433390869938191d09bdb0680` and recorded bounded patch, MiniMax M2.7 | Native task/task_append/task_stop, protected workspace tools | No qualified close/reopen; native workers do not delegate nested work; public function/MCP combinations remain unsupported | + +Native probes separately verified concurrency admission, child credential/history +protection and cancellation settlement under each supported profile. MiniMax's +initial child-tool isolation failure and Claude's initial input-projection failure +remain failed evidence; subsequent fixed executions supply the acceptance proof. +The MiniMax M3/Codex empty-tool-argument failure remains a separate model-profile +investigation; Core does not repair model output. + +Evidence root on `zju_a100_2`: +`~/.parsar/remediation/20260922/subagent-contract/`. Public proofs are in +`public-codex-kimi1`, `public-claude_sdk-2` and `public-mcode-1`; native mechanism +proofs are in `native-proof`, `claude-native` and `mcode-native`. The shared script +`scripts/agents-api-subagents-acceptance.py --phase spawn-direct` validates common +reads; its `resources_passed` and `requested_phase_passed` fields qualify that +phase. Its aggregate `passed` field additionally requires the optional Codex +close/reopen scenario. Do not require unsupported native close operations merely +to make that separate aggregate flag true. + +This batch does not rerun the E2B deployment matrix or establish live child-delta +timing equivalence. Claude and MiniMax publish verified child history at settlement; +the accepted read/recovery workflow must not be advertised as continuous native +child progress streaming. Existing single-Agent deployment evidence retains its +original scope. + +Still unconfirmed upstream semantics include root-completion child propagation, +complete child-delta ordering and Session Usage aggregation. Unlimited background +work across root Turns, complete multi-agent conformance and business Teams are +not established by these six resource reads. diff --git a/contracts/agents-api/v1/events.go b/contracts/agents-api/v1/events.go index 38b32ee0f..8d6d20ed9 100644 --- a/contracts/agents-api/v1/events.go +++ b/contracts/agents-api/v1/events.go @@ -2,6 +2,7 @@ package v1 // SessionEvent contains the supported live event variants of the pinned protocol. type SessionEvent struct { + Subagent *Subagent `json:"subagent,omitempty"` Type string `json:"type" binding:"required"` EventID string `json:"event_id" binding:"required"` SessionID string `json:"session_id,omitempty"` diff --git a/contracts/agents-api/v1/items.go b/contracts/agents-api/v1/items.go index d8db4d126..83071f156 100644 --- a/contracts/agents-api/v1/items.go +++ b/contracts/agents-api/v1/items.go @@ -7,30 +7,38 @@ import ( // Item contains the supported variants of the pinned Session Item union. type Item struct { - ID string `json:"id" binding:"required"` - TurnID string `json:"turn_id" binding:"required"` - Type string `json:"type" binding:"required" enums:"message,command_execution,mcp_call,function_call,function_call_output,web_search_call"` - Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` - Role string `json:"role,omitempty" enums:"user,assistant"` - Phase string `json:"phase,omitempty" enums:"commentary,final_answer"` - Content []ItemContent `json:"content,omitempty"` - Command string `json:"command,omitempty"` - Cwd *string `json:"cwd,omitempty"` - DurationMS *int64 `json:"duration_ms,omitempty"` - ExitCode *int64 `json:"exit_code,omitempty"` - Name string `json:"name,omitempty"` - CallID string `json:"call_id,omitempty"` - ServerLabel string `json:"server_label,omitempty"` - Arguments any `json:"arguments,omitempty"` - Output any `json:"output,omitempty"` - Error any `json:"error,omitempty"` - Action *WebSearchAction `json:"action,omitempty"` + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"message,command_execution,mcp_call,function_call,function_call_output,web_search_call,reasoning,agent_message,create_subagent_call,send_subagent_input_call,resume_subagent_call,wait_for_subagents_call,interrupt_subagent_call,close_subagent_call"` + Status string `json:"status" enums:"in_progress,completed,failed,incomplete"` + Role string `json:"role,omitempty" enums:"user,assistant"` + Phase string `json:"phase,omitempty" enums:"commentary,final_answer"` + Content []ItemContent `json:"content,omitempty"` + Command string `json:"command,omitempty"` + Cwd *string `json:"cwd,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + Name string `json:"name,omitempty"` + CallID string `json:"call_id,omitempty"` + ServerLabel string `json:"server_label,omitempty"` + Arguments any `json:"arguments,omitempty"` + Output any `json:"output,omitempty"` + Error any `json:"error,omitempty"` + Action *WebSearchAction `json:"action,omitempty"` + AgentID string `json:"agent_id,omitempty"` + SenderAgentID string `json:"sender_agent_id,omitempty"` + RecipientAgentID string `json:"recipient_agent_id,omitempty"` + RecipientAgentIDs []string `json:"recipient_agent_ids,omitempty"` + Model *string `json:"model,omitempty"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` + Summary []SummaryText `json:"summary,omitempty"` } type ItemContent struct { - Type string `json:"type" binding:"required" enums:"input_text,output_text,input_image"` - Text *string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` + Type string `json:"type" binding:"required" enums:"input_text,output_text,input_image,encrypted_content"` + Text *string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` + EncryptedContent *string `json:"encrypted_content,omitempty"` } type WebSearchAction struct { diff --git a/contracts/agents-api/v1/subagent_items.go b/contracts/agents-api/v1/subagent_items.go new file mode 100644 index 000000000..205806d15 --- /dev/null +++ b/contracts/agents-api/v1/subagent_items.go @@ -0,0 +1,130 @@ +package v1 + +import ( + "encoding/json" + "errors" +) + +type CreateSubagentCallItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"create_subagent_call"` + Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` + AgentID string `json:"agent_id" binding:"required"` + Content []AgentContent `json:"content" binding:"required"` + Model *string `json:"model" extensions:"x-nullable"` + ReasoningEffort *string `json:"reasoning_effort" extensions:"x-nullable"` +} + +type SendSubagentInputCallItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"send_subagent_input_call"` + Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` + SenderAgentID string `json:"sender_agent_id" binding:"required"` + RecipientAgentID string `json:"recipient_agent_id" binding:"required"` + Content []AgentContent `json:"content" binding:"required"` +} + +// SubagentControlCallItem is the common wire shape of resume, interrupt and close calls. +type SubagentControlCallItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"resume_subagent_call,interrupt_subagent_call,close_subagent_call"` + Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` + SenderAgentID string `json:"sender_agent_id" binding:"required"` + RecipientAgentID string `json:"recipient_agent_id" binding:"required"` +} + +type WaitForSubagentsCallItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"wait_for_subagents_call"` + Status string `json:"status" binding:"required" enums:"in_progress,completed,failed,incomplete"` + SenderAgentID string `json:"sender_agent_id" binding:"required"` + RecipientAgentIDs []string `json:"recipient_agent_ids" binding:"required"` +} + +type AgentMessageItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"agent_message"` + SenderAgentID string `json:"sender_agent_id" binding:"required"` + RecipientAgentID string `json:"recipient_agent_id" binding:"required"` + Content []AgentContent `json:"content" binding:"required"` +} + +type SummaryText struct { + Type string `json:"type" binding:"required" enums:"summary_text"` + Text string `json:"text" binding:"required"` +} + +type ReasoningItem struct { + ID string `json:"id" binding:"required"` + TurnID string `json:"turn_id" binding:"required"` + Type string `json:"type" binding:"required" enums:"reasoning"` + Status *string `json:"status" extensions:"x-nullable" enums:"in_progress,completed,incomplete"` + Summary []SummaryText `json:"summary" binding:"required"` +} + +// MarshalJSON preserves each coordination variant's required fields and nulls. +// Existing execution Item variants retain their established serialization. +func (i Item) MarshalJSON() ([]byte, error) { + switch i.Type { + case "create_subagent_call", "send_subagent_input_call", "agent_message": + content, err := coordinationContent(i.Content) + if err != nil { + return nil, err + } + switch i.Type { + case "create_subagent_call": + return json.Marshal(CreateSubagentCallItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: i.Status, AgentID: i.AgentID, Content: content, Model: i.Model, ReasoningEffort: i.ReasoningEffort}) + case "send_subagent_input_call": + return json.Marshal(SendSubagentInputCallItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: i.Status, SenderAgentID: i.SenderAgentID, RecipientAgentID: i.RecipientAgentID, Content: content}) + default: + return json.Marshal(AgentMessageItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, SenderAgentID: i.SenderAgentID, RecipientAgentID: i.RecipientAgentID, Content: content}) + } + case "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call": + return json.Marshal(SubagentControlCallItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: i.Status, SenderAgentID: i.SenderAgentID, RecipientAgentID: i.RecipientAgentID}) + case "wait_for_subagents_call": + recipients := i.RecipientAgentIDs + if recipients == nil { + recipients = []string{} + } + return json.Marshal(WaitForSubagentsCallItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: i.Status, SenderAgentID: i.SenderAgentID, RecipientAgentIDs: recipients}) + case "reasoning": + var status *string + if i.Status != "" { + status = &i.Status + } + summary := i.Summary + if summary == nil { + summary = []SummaryText{} + } + return json.Marshal(ReasoningItem{ID: i.ID, TurnID: i.TurnID, Type: i.Type, Status: status, Summary: summary}) + default: + type wire Item + return json.Marshal(wire(i)) + } +} + +func coordinationContent(parts []ItemContent) ([]AgentContent, error) { + result := make([]AgentContent, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "output_text": + if part.Text == nil { + return nil, errors.New("missing inter-agent text") + } + result = append(result, AgentContent{Type: part.Type, Text: part.Text}) + case "encrypted_content": + if part.EncryptedContent == nil { + return nil, errors.New("missing encrypted inter-agent content") + } + result = append(result, AgentContent{Type: part.Type, EncryptedContent: part.EncryptedContent}) + default: + return nil, errors.New("unsupported inter-agent content type") + } + } + return result, nil +} diff --git a/contracts/agents-api/v1/subagents.go b/contracts/agents-api/v1/subagents.go new file mode 100644 index 000000000..a0388816e --- /dev/null +++ b/contracts/agents-api/v1/subagents.go @@ -0,0 +1,25 @@ +package v1 + +// AgentContent is the pinned content union used for inter-agent instructions and messages. +type AgentContent struct { + Type string `json:"type" binding:"required" enums:"output_text,encrypted_content"` + Text *string `json:"text,omitempty"` + EncryptedContent *string `json:"encrypted_content,omitempty"` +} + +type Subagent struct { + ID string `json:"id" binding:"required"` + Object string `json:"object" binding:"required" enums:"agent.session.subagent"` + SessionID string `json:"session_id" binding:"required"` + ParentAgentID string `json:"parent_agent_id" binding:"required"` + OpenedAt int64 `json:"opened_at" binding:"required"` + ClosedAt *int64 `json:"closed_at" extensions:"x-nullable"` + Name *string `json:"name" extensions:"x-nullable"` + Instructions []AgentContent `json:"instructions" extensions:"x-nullable"` + Status string `json:"status" binding:"required" enums:"active,closed"` +} + +type SubagentList struct { + Data []Subagent `json:"data" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` +} diff --git a/contracts/agents-api/v1/subagents_test.go b/contracts/agents-api/v1/subagents_test.go new file mode 100644 index 000000000..798c8c35c --- /dev/null +++ b/contracts/agents-api/v1/subagents_test.go @@ -0,0 +1,101 @@ +package v1 + +import ( + "encoding/json" + "reflect" + "testing" +) + +func assertJSONEqual(t *testing.T, got []byte, want string) { + t.Helper() + var actual, expected any + if err := json.Unmarshal(got, &actual); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(want), &expected); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("wire shape:\n got %s\nwant %s", got, want) + } +} + +func TestSubagentWireShapePreservesNullableMetadata(t *testing.T) { + for _, raw := range []string{ + `{"id":"child","object":"agent.session.subagent","session_id":"session","parent_agent_id":"root","opened_at":1700000000,"closed_at":null,"name":null,"instructions":null,"status":"active"}`, + `{"id":"nested","object":"agent.session.subagent","session_id":"session","parent_agent_id":"child","opened_at":1700000001,"closed_at":1700000002,"name":"reviewer","instructions":[{"type":"output_text","text":"Review."},{"type":"encrypted_content","encrypted_content":"opaque"}],"status":"closed"}`, + } { + var value Subagent + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatal(err) + } + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, body, raw) + } +} + +func TestTurnWireShapeIncludesNullableSubagentIdentity(t *testing.T) { + turn := Turn{ID: "turn", AgentID: "root", SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000000} + body, err := json.Marshal(turn) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, body, `{"id":"turn","agent_id":"root","subagent_id":null,"session_id":"session","object":"agent.session.turn","status":"completed","created_at":1700000000,"started_at":null,"completed_at":null,"error":null,"usage":null}`) + child := "child" + turn.AgentID, turn.SubagentID = child, &child + body, err = json.Marshal(turn) + if err != nil { + t.Fatal(err) + } + var returned Turn + if err := json.Unmarshal(body, &returned); err != nil || returned.SubagentID == nil || *returned.SubagentID != child || returned.AgentID != child { + t.Fatalf("child identity: %s (%v)", body, err) + } +} + +func TestCoordinationItemsMatchPinnedWireShapes(t *testing.T) { + fixtures := []string{ + `{"id":"create","turn_id":"turn","type":"create_subagent_call","status":"completed","agent_id":"root","content":[],"model":null,"reasoning_effort":null}`, + `{"id":"create-model","turn_id":"turn","type":"create_subagent_call","status":"failed","agent_id":"root","content":[{"type":"output_text","text":""}],"model":"requested-model","reasoning_effort":"high"}`, + `{"id":"send","turn_id":"turn","type":"send_subagent_input_call","status":"completed","sender_agent_id":"root","recipient_agent_id":"child","content":[{"type":"encrypted_content","encrypted_content":"opaque"}]}`, + `{"id":"wait","turn_id":"turn","type":"wait_for_subagents_call","status":"in_progress","sender_agent_id":"root","recipient_agent_ids":["child","other"]}`, + `{"id":"resume","turn_id":"turn","type":"resume_subagent_call","status":"completed","sender_agent_id":"root","recipient_agent_id":"child"}`, + `{"id":"interrupt","turn_id":"turn","type":"interrupt_subagent_call","status":"incomplete","sender_agent_id":"root","recipient_agent_id":"child"}`, + `{"id":"close","turn_id":"turn","type":"close_subagent_call","status":"failed","sender_agent_id":"root","recipient_agent_id":"child"}`, + `{"id":"message","turn_id":"turn","type":"agent_message","sender_agent_id":"child","recipient_agent_id":"root","content":[{"type":"output_text","text":"Result."}]}`, + `{"id":"reasoning","turn_id":"turn","type":"reasoning","status":null,"summary":[]}`, + `{"id":"reasoning","turn_id":"turn","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"Checked."}]}`, + } + for _, raw := range fixtures { + var item Item + if err := json.Unmarshal([]byte(raw), &item); err != nil { + t.Fatal(err) + } + // Irrelevant fields from other variants must not leak into public coordination Items. + item.Command, item.Name = "private command", "native name" + if item.Type == "agent_message" { + item.Status = "completed" + } + body, err := json.Marshal(item) + if err != nil { + t.Fatal(err) + } + assertJSONEqual(t, body, raw) + } +} + +func TestCoordinationItemsDoNotEncodeInputContentAsAgentContent(t *testing.T) { + text := "instruction" + for _, content := range []ItemContent{ + {Type: "input_text", Text: &text}, + {Type: "output_text"}, + {Type: "encrypted_content"}, + } { + if _, err := json.Marshal(Item{Type: "agent_message", Content: []ItemContent{content}}); err == nil { + t.Fatalf("accepted invalid content: %+v", content) + } + } +} diff --git a/contracts/agents-api/v1/turns.go b/contracts/agents-api/v1/turns.go index 7e5712c23..ea112a260 100644 --- a/contracts/agents-api/v1/turns.go +++ b/contracts/agents-api/v1/turns.go @@ -3,6 +3,7 @@ package v1 type Turn struct { ID string `json:"id" binding:"required"` AgentID string `json:"agent_id" binding:"required"` + SubagentID *string `json:"subagent_id" extensions:"x-nullable"` SessionID string `json:"session_id" binding:"required"` Object string `json:"object" enums:"agent.session.turn" binding:"required"` Status string `json:"status" enums:"queued,in_progress,waiting,completed,failed,cancelled" binding:"required"` @@ -14,7 +15,7 @@ type Turn struct { } type TurnError struct { - Code string `json:"code" enums:"internal_error" binding:"required"` + Code string `json:"code" enums:"context_length_exceeded,session_budget_exceeded,usage_limit_exceeded,rate_limit_exceeded,server_overloaded,cyber_policy,connection_failed,server_error,authentication_error,invalid_request,resource_not_found,sandbox_error,executor_version_incompatible,active_turn_not_steerable,request_timeout,internal_error" binding:"required"` Message string `json:"message" binding:"required"` } diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go index 9d411adb3..cf510b4fb 100644 --- a/internal/agentdaemon/device/state.go +++ b/internal/agentdaemon/device/state.go @@ -60,6 +60,7 @@ type HeartbeatStatus struct { // KindCapabilities mirrors the daemon heartbeat capability // shape after gateway-level normalization. Persistence stays separate from wire protocol structs. type KindCapabilities struct { + SubagentObservations bool `json:"subagent_observations,omitempty"` Streaming bool `json:"streaming,omitempty"` Permissions bool `json:"permissions,omitempty"` Usage bool `json:"usage,omitempty"` diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index 2d9a7aa7b..901b60014 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -562,6 +562,7 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK TextVerbosity: info.Capabilities.TextVerbosity, ExecutionControls: info.Capabilities.ExecutionControls, SubagentControl: info.Capabilities.SubagentControl, + SubagentObservations: info.Capabilities.SubagentObservations, FunctionTools: info.Capabilities.FunctionTools, MCPHTTPTools: info.Capabilities.MCPHTTPTools, MCPHTTPRequired: info.Capabilities.MCPHTTPRequired, diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 765e61990..a5b3cb3f4 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -233,10 +233,12 @@ type ErrorPayload struct { // embedded) so a refactor of PromptOutput doesn't silently flip the // wire shape. type DonePayload struct { - Content string `json:"content"` - Transcript string `json:"transcript,omitempty"` - Usage Usage `json:"usage,omitzero"` - Metadata map[string]any `json:"metadata,omitempty"` + // SourceCompletedAtMS freezes the native root completion before child settlement. + SourceCompletedAtMS *int64 `json:"source_completed_at_ms,omitempty"` + Content string `json:"content"` + Transcript string `json:"transcript,omitempty"` + Usage Usage `json:"usage,omitzero"` + Metadata map[string]any `json:"metadata,omitempty"` } const ( @@ -249,6 +251,7 @@ const ( // cancellation belong to the daemon connector itself; these bits are // the engine-specific surface the UI uses for filtering and copy. type AgentKindCapabilities struct { + SubagentObservations bool `json:"subagent_observations,omitempty"` Streaming bool `json:"streaming,omitempty"` Permissions bool `json:"permissions,omitempty"` Usage bool `json:"usage,omitempty"` diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index adb71b78b..71dee3142 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -35,6 +35,7 @@ const ( // PromptInput so future agent implementations can evolve the wire shape // without touching the connector surface. type PromptRequestPayload struct { + MaxConcurrentSubagents *int `json:"max_concurrent_subagents,omitempty"` // AgentKind selects which agent implementation the daemon // dispatches to. AgentKind string `json:"agent_kind"` diff --git a/internal/agentdaemon/proto/subagents.go b/internal/agentdaemon/proto/subagents.go index 73a984bba..63559171b 100644 --- a/internal/agentdaemon/proto/subagents.go +++ b/internal/agentdaemon/proto/subagents.go @@ -1,14 +1,78 @@ package proto -// TypeSubagentIdentity carries verified native identity facts, not public lifecycle. +import "encoding/json" + +// TypeSubagentIdentity carries verified native identity facts; lifecycle uses a separate effect. const TypeSubagentIdentity = "subagent_identity" // SubagentIdentityPayload is scoped by the authenticated Run envelope. The service // supplies its Session, project, device and public identity; none comes from here. type SubagentIdentityPayload struct { - NativeID string `json:"native_id"` - ParentNativeID string `json:"parent_native_id"` - NativeCreatedAt int64 `json:"native_created_at"` - ParentTurnID string `json:"parent_turn_id"` - SourceItemID string `json:"source_item_id"` + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + ParentTurnID string `json:"parent_turn_id"` + SourceItemID string `json:"source_item_id"` + Name *string `json:"name"` + Instructions *string `json:"instructions"` +} + +const ( + TypeSubagentLifecycle = "subagent_lifecycle" + TypeSubagentTurn = "subagent_turn" + TypeSubagentItem = "subagent_item" +) + +// SubagentLifecyclePayload reports a successful native effect, never a task's +// completion or a process disconnect. EffectID is stable across history reads. +// Active is emitted only for an actual reopen; an already-active resume is a no-op. +type SubagentLifecyclePayload struct { + NativeID string `json:"native_id"` + EffectID string `json:"effect_id"` + Status string `json:"status"` + OccurredAtMS int64 `json:"occurred_at_ms"` +} + +// SubagentTurnPayload describes child-owned work. Timestamps are native facts or +// an immutable receipt of confirmed cancellation when native history omits it. +// Converting seconds to milliseconds does not establish finer precision. +// Emit identity first, then an active Turn, its Items, and its terminal snapshot. +// Unknown token measurements stay nil. Core owns all public resource IDs. +type SubagentTurnPayload struct { + NativeID string `json:"native_id"` + TurnID string `json:"turn_id"` + Status string `json:"status"` + CreatedAtMS int64 `json:"created_at_ms"` + StartedAtMS *int64 `json:"started_at_ms"` + CompletedAtMS *int64 `json:"completed_at_ms"` + Usage *UsagePayload `json:"usage"` +} + +// SubagentItemPayload reuses ordinary message/tool observations. Payload is a +// complete snapshot, not a delta. Position is the native order within its Turn. +// NativeItem and engine-specific payloads are not admitted by this contract. +type SubagentItemPayload struct { + NativeID string `json:"native_id"` + TurnID string `json:"turn_id"` + ItemID string `json:"item_id"` + Position int32 `json:"position"` + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +const TypeSubagentCoordination = "subagent_coordination" + +// SubagentCoordinationPayload reports the native coordination operation in the +// common vocabulary. Actor/recipients are native identities, resolved by Core. +// An empty ActorID denotes the authenticated root, never an arbitrary Agent. +// A child-owned operation is carried as a SubagentItem with this payload. +type SubagentCoordinationPayload struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status string `json:"status"` + ActorID string `json:"actor_id"` + Recipients []string `json:"recipients"` + Text *string `json:"text"` + Model *string `json:"model"` + ReasoningEffort *string `json:"reasoning_effort"` } diff --git a/packages/claude-sdk-adapter/SUBAGENTS.md b/packages/claude-sdk-adapter/SUBAGENTS.md new file mode 100644 index 000000000..f5a9eeb4f --- /dev/null +++ b/packages/claude-sdk-adapter/SUBAGENTS.md @@ -0,0 +1,56 @@ +# Native subagent observations + +The adapter uses the pinned Claude Agent SDK 0.3.269 and its native Agent and +SendMessage execution. It does not implement a model loop. An explicit Runtime +request enables `parsar_worker`; ordinary requests retain their previous tools. +The packaged `subagent_resources` readiness feature gates this request. + +A child identity comes from native task admission and persisted child metadata. +The metadata's `toolUseId` must identify the parent's original Agent call; +`parentAgentId` identifies a nested parent, and root children require native +`spawnDepth: 1`. The child history must belong to the same root Session and child +ID. The SDK resolves its persisted conversation chain; the adapter reads the +corresponding private original records for ownership and timestamps that the +SDK's public TypeScript message shape omits. + +The first own native user record has a null `parentUuid`. Its timestamp supplies +`opened_at`, in seconds, and the first Turn's creation and start time, in +milliseconds. This is the original input timestamp, not discovery time or a +copied parent record. The fixed native metadata has no separate creation +timestamp. Reopening the Runtime preserves these values. Each subsequent own +input starts a child Turn; native end-turn assistant records supply completion +time. Own messages, reasoning and native Bash receipts retain native IDs and +ordering. Completion leaves the Subagent active and idle: the adapter emits no +closed state because this native profile has no qualified close operation. + +The existing native query owns child execution and cleanup. PreToolUse admission +reserves each native Agent or idle-child SendMessage call before execution. +Native `task_started` associates the call and child ID; completion releases that +reservation. The frozen limit applies across the child tree, excludes the root, +and defaults to six. Unknown call associations fail closed. SendMessage to a running child is rejected; only idle continuation is qualified. +Native background +execution, alternate agent types, worktree isolation and per-call model overrides +are not admitted in this profile. + +Workspace children use Bash under the existing WorkspaceProfile hooks and native +sandbox. They do not receive unrestricted private-file tools. The parent retains +its existing workspace tool policy. Functions and MCP with subagents are not +qualified combinations and are rejected explicitly. This does not affect the +existing functions/MCP paths without subagents. + +The adapter mechanism qualification uses real Kimi calls in the accepted Docker +Runtime, including two child identities, their own histories, workspace writes, +private credential/history/proc-read denial, strict concurrent admission and +same-ID continuation from a new process. These checks are distinct from the +Core public resource, pagination and isolation acceptance performed by the +integration suite. + +Cancellation uses an adapter-owned effect receipt only after the existing query +owner confirms native process exit. The fixed native history can end at a tool +call without a cancellation result or timestamp. Each receipt is linked into +the protected native history directory atomically, without overwriting an +earlier receipt, and records the child, own Turn, spawn call and confirmed effect +time. Native records remain unchanged. Replay uses that same timestamp; it +never obtains a new cancellation time by observing an unfinished history. Child +Items and the cancelled Turn precede the root cancellation event. Missing native +exit confirmation or a missing receipt does not imply a terminal child state. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 99f377e37..6570eafd3 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -1,3 +1,5 @@ +import { Subagents } from "./subagents.js"; +import type { Fact } from "./subagent_history.js"; import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_directories.js"; import { getSessionInfo, query, startup, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; import { WorkspaceReads, type WorkspaceReadEvent } from "./workspace_reads.js"; @@ -16,6 +18,7 @@ import { recoverSession } from "./recovery.js"; export { parseStart, type Start } from "./request.js"; export type Event = + | Fact | WorkspaceDirectoryEvent | WorkspaceReadEvent | MessageEvent @@ -34,7 +37,8 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => const names = definitions.map(tool => `mcp__functions__${tool.name}`); const declarations = request.workspace?.mcp ?? request.mcp_http_servers; const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); - const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names, profile); + const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume) : undefined; + const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names, profile, subagents); const commands = workspace ? new CommandObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); @@ -50,6 +54,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => await emit({ type: "error", code: "history_unavailable" }); return; } + subagents?.expectSession(request.resume); const mcpServers: Record = Object.create(null); if (definitions.length) mcpServers.functions = createFunctionServer(definitions, functions.invoke); const mcp = profile ? new MCPObserver(profile.identities) : undefined; @@ -59,6 +64,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => let nativeID = ""; const resultIDs = new Set(); let failed = false; + let cancellationFactsFailed = false; const messages = request.observe_messages ? new MessageObserver() : undefined; let stream: ReturnType | undefined; let warm: WarmQuery | undefined; @@ -69,11 +75,11 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => if (abort.signal.aborted) throw new Error("cancelled"); const options: Options = { cwd: request.cwd, - env: workspace?.options.env ?? { ...process.env }, + env: workspace?.options.env ?? { ...process.env, ...(subagents ? { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } : {}) }, model: request.model, systemPrompt: request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), - tools: [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], + tools: subagents ? ["Agent", "SendMessage"] : [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], ...(profile && !workspace ? { agent: "parsar_root", disallowedTools: profile.denied, hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, @@ -84,6 +90,14 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => canUseTool: async () => ({ behavior: "deny", message: "Tools are unavailable in this execution profile." }), ...(workspace?.options ?? {}), mcpServers, + ...(subagents ? { ...subagents.options(!!workspace), hooks: { + Stop: [{ hooks: [subagents.stopped] }], SubagentStart: [{ hooks: [subagents.childStart] }], + PostToolUseFailure: [{ hooks: [subagents.failedTool] }], + PreToolUse: [{ hooks: [async (input, id, context) => { + if (input.hook_event_name === "PreToolUse" && subagents.isCoordination(input.tool_name)) return subagents.beforeTool(input, id, context); + return workspace ? workspace.beforeTool(input, id, context) : {}; + }] }], + } } : {}), spawnClaudeCodeProcess: options => { const child = spawnNative(options); nativeAlive = true; @@ -113,6 +127,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => inputs.release(request.prompt); } else stream = query({ prompt: inputs, options }); for await (const message of stream) { + subagents?.consume(message); await functions.consume(message, nativeID); if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); @@ -122,7 +137,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); if (workspace) workspace.verify(message.tools, profile ? await stream.mcpServerStatus() : message.mcp_servers, nativeID); else if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID); - else if (message.tools.length !== names.length || message.tools.some(name => !names.includes(name)) || + else if (message.tools.length !== names.length + (subagents ? 2 : 0) || message.tools.some(name => ![...names, ...(subagents ? ["Task", "SendMessage"] : [])].includes(name)) || message.mcp_servers.length !== (definitions.length ? 1 : 0) || message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected")) { throw new Error("unexpected native configuration"); @@ -141,6 +156,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => } if (message.type !== "result") for (const event of inputs.consume(message)) await emit(event); } + if (subagents) for (const event of await subagents.facts()) await emit(event); functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); @@ -157,10 +173,15 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => abort.signal.removeEventListener("abort", closeInputs); const exits = await Promise.all(children); if (!exits.length || exits.some(code => code !== 0)) failed = true; + if (subagents && abort.signal.aborted && exits.length === 1) { + try { for (const event of await subagents.facts(Date.now())) await emit(event); } + catch { cancellationFactsFailed = true; } + } if (mcp) for (const event of mcp.close()) await emit(event); if (commands) for (const event of commands.close()) await emit(event); } - if (abort.signal.aborted) await emit({ type: "error", code: "cancelled" }); + if (cancellationFactsFailed) await emit({ type: "error", code: "execution_failed" }); + else if (abort.signal.aborted) await emit({ type: "error", code: "cancelled" }); else if (failed || !result || !inputs.complete) await emit({ type: "error", code: "execution_failed" }); else await emit(result); } diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 83d873c80..3d0e6d331 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -12,6 +12,7 @@ export type Start = { resume?: string; require_history?: boolean; observe_messages?: boolean; + subagents?: { max_concurrent: number }; functions?: { name: string; description: string; parameters: Tool["inputSchema"] }[]; mcp_http_servers?: HTTPServer[]; workspace?: Workspace; @@ -27,7 +28,7 @@ export function parseRequest(line: string): Start | Prepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["type", "prompt", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "functions", "mcp_http_servers", "workspace"]); + const allowed = new Set(["type", "prompt", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "subagents", "functions", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare") || (request.type === "start" ? typeof request.prompt !== "string" || !request.prompt.trim() : "prompt" in request) || @@ -40,8 +41,14 @@ export function parseRequest(line: string): Start | Prepare { if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || !tool.parameters || tool.parameters.type !== "object"))) throw new Error("invalid_request"); + if (request.subagents !== undefined) { + const value = request.subagents as Record; + if (!value || typeof value !== "object" || Object.keys(value).length !== 1 || !Number.isSafeInteger(value.max_concurrent) || (value.max_concurrent as number) < 1 || + (request.functions as unknown[] | undefined)?.length || request.mcp_http_servers !== undefined) throw new Error("invalid_request"); + } parseHTTPServers(request.mcp_http_servers); const workspace = parseWorkspace(request.workspace, request.cwd); + if (request.subagents && workspace?.mcp?.length) throw new Error("invalid_request"); if (request.require_history && !workspace) throw new Error("invalid_request"); if ((workspace && "mcp_http_servers" in request) || (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 8b819e545..9935c880b 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/subagent_history.ts b/packages/claude-sdk-adapter/src/subagent_history.ts new file mode 100644 index 000000000..b7e6aa0d0 --- /dev/null +++ b/packages/claude-sdk-adapter/src/subagent_history.ts @@ -0,0 +1,222 @@ +import { readFile, realpath, stat, writeFile, link, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { dirname, join, relative } from "node:path"; + +export type Fact = { type: "subagent_identity" | "subagent_turn" | "subagent_item" | "subagent_coordination"; fact: Record }; +export type NativeRow = { + type: string; uuid?: string; parentUuid?: string | null; sessionId?: string; agentId?: string; + isSidechain?: boolean; timestamp?: string; toolUseResult?: Record; + message?: { id?: string; role?: string; content?: unknown; stop_reason?: string | null }; +}; +type Call = { id: string; name: string; input: Record; turn: string; result?: Record; error?: boolean; finished?: boolean }; +export type Cancellation = { root: string; native_id: string; turn_id: string; source_item_id: string; last_item_id: string; completed_at_ms: number }; +type Entry = { id: string; metadata: Record; rows: NativeRow[]; cancellations?: Cancellation[] }; +type Child = { id: string; parent: string; call: Call; rows: NativeRow[]; opened: number; cancellations: Cancellation[] }; +const identity = /^[A-Za-z0-9_-]{1,160}$/; +function fail(): never { throw new Error("invalid native child history"); } +const blocks = (r: NativeRow): Record[] => Array.isArray(r.message?.content) ? r.message.content as Record[] : []; +const input = (r: NativeRow) => r.type === "user" && !blocks(r).some(b => b.type === "tool_result"); +const text = (r: NativeRow): string => typeof r.message?.content === "string" ? r.message.content : blocks(r).filter(b => b.type === "text").map(b => b.text).join(""); +function time(r: NativeRow): number { const n = typeof r.timestamp === "string" ? Date.parse(r.timestamp) : NaN; return Number.isFinite(n) && n > 0 ? n : fail(); } + +function calls(rows: NativeRow[]): Map { + let turn = ""; const result = new Map(); + for (const row of rows) { + if (input(row)) turn = row.uuid ?? ""; + for (const b of blocks(row)) { + if (b.type === "tool_use") { + if (typeof b.id !== "string" || typeof b.name !== "string" || !b.input || typeof b.input !== "object" || !turn) fail(); + if (result.has(b.id as string)) fail(); + result.set(b.id as string, { id: b.id as string, name: b.name as string, input: b.input as Record, turn }); + } else if (b.type === "tool_result") { + const call = result.get(b.tool_use_id as string); + if (call) { call.finished = true; call.result = row.toolUseResult; call.error = b.is_error === true; } + } + } + } + return result; +} + +export function projectHistory(root: string, rootRows: NativeRow[], entries: Entry[]): Fact[] { + const facts: Fact[] = [], children = new Map(); + const byParent = new Map>([[root, calls(rootRows)]]); + for (const entry of entries) byParent.set(entry.id, calls(entry.rows)); + for (const { id, metadata, rows, cancellations = [] } of entries) { + if (!identity.test(id) || !rows.length || rows.some(r => r.sessionId !== root || r.agentId !== id || r.isSidechain !== true)) fail(); + const first = rows.find(r => r.type === "user" || r.type === "assistant"); + if (!first || !input(first) || first.parentUuid !== null || !first.uuid) fail(); + const parent = typeof metadata.parentAgentId === "string" ? metadata.parentAgentId : root; + if (parent === id || (parent === root && metadata.spawnDepth !== 1)) fail(); + const call = byParent.get(parent)?.get(metadata.toolUseId as string); + if (!call || !["Agent", "Task"].includes(call.name) || call.input.prompt !== text(first)) fail(); + for (const receipt of cancellations) { + const own = rows.find(r => input(r) && r.uuid === receipt.turn_id); + if (!own || receipt.root !== root || receipt.native_id !== id || receipt.source_item_id !== call.id || + !rows.some(r => r.uuid === receipt.last_item_id) || !Number.isSafeInteger(receipt.completed_at_ms) || receipt.completed_at_ms < time(own)) fail(); + } + if (call.result?.agentId !== id && !cancellations.some(r => r.turn_id === first.uuid)) fail(); + children.set(id, { id, parent, call, rows, opened: time(first), cancellations }); + } + const ordered: Child[] = [], pending = new Map(children); + while (pending.size) { + const ready = [...pending.values()].filter(c => c.parent === root || ordered.some(p => p.id === c.parent)); + if (!ready.length) fail(); + for (const child of ready) { ordered.push(child); pending.delete(child.id); } + } + for (const child of ordered) facts.push({ type: "subagent_identity", fact: { + native_id: child.id, parent_native_id: child.parent, native_created_at: Math.floor(child.opened / 1000), + parent_turn_id: child.call.turn, source_item_id: child.call.id, name: typeof child.call.input.name === "string" ? child.call.input.name : null, + instructions: null, + } }); + for (const child of ordered) facts.push(...projectChild(child, children)); + for (const call of byParent.get(root)!.values()) { + const coordination = coordinationFact(call, "", children); + if (coordination) facts.push({ type: "subagent_coordination", fact: coordination }); + } + return facts; +} + +function coordinationFact(call: Call, actor: string, children: Map): Record | undefined { + const spawn = ["Agent", "Task"].includes(call.name), send = call.name === "SendMessage"; + if (!spawn && !send) return; + const recipient = spawn ? call.result?.agentId ?? [...children.values()].find(c => c.call.id === call.id)?.id : call.input.to; + if (!call.error && (typeof recipient !== "string" || !children.has(recipient))) fail(); + return { id: call.id, kind: spawn ? "create_subagent_call" : "send_subagent_input_call", status: call.error || !call.finished ? "failed" : "completed", + actor_id: actor, recipients: typeof recipient === "string" ? [recipient] : [], text: typeof (spawn ? call.input.prompt : call.input.message) === "string" ? (spawn ? call.input.prompt : call.input.message) : null, + model: null, reasoning_effort: null }; +} + +function projectChild(child: Child, children: Map): Fact[] { + const facts: Fact[] = [], turns: NativeRow[][] = []; + for (const row of child.rows) { + if (input(row)) turns.push([]); + if (turns.length) turns[turns.length - 1].push(row); + } + for (let rows of turns) { + const first = rows[0], turn = first.uuid!, created = time(first); + const cancellation = child.cancellations.find(r => r.turn_id === turn); + if (cancellation) { + const last = rows.findIndex(r => r.uuid === cancellation.last_item_id); + if (last < 0) fail(); + rows = rows.slice(0, last + 1); + } + const state = { native_id: child.id, turn_id: turn, created_at_ms: created, started_at_ms: created }; + facts.push({ type: "subagent_turn", fact: { ...state, status: "in_progress" } }); + let position = 0; + const item = (id: string, kind: string, payload: Record) => facts.push({ type: "subagent_item", fact: { + native_id: child.id, turn_id: turn, item_id: id, position: position++, kind, payload, + } }); + item(first.uuid!, "message", { input: [{ role: "user", content: [{ type: "input_text", text: text(first) }] }] }); + const messages = new Map(); + const order: { id: string; kind: "text" | "thinking" | "tool" }[] = [], seen = new Set(); + const toolCalls = calls(rows); + let completed: number | undefined; + for (const row of rows) { + if (row.type !== "assistant") continue; + const id = row.message?.id; + if (!id) fail(); + let message = messages.get(id); + if (!message) { message = { text: "", thinking: "" }; messages.set(id, message); } + for (const block of blocks(row)) { + if (block.type === "text" && typeof block.text === "string") message.text += block.text; + else if (block.type === "thinking" && typeof block.thinking === "string") message.thinking += block.thinking; + const kind = block.type === "text" ? "text" : block.type === "thinking" ? "thinking" : block.type === "tool_use" ? "tool" : undefined; + if (kind) { + const key = kind === "tool" ? block.id as string : id; + if (!seen.has(kind + ":" + key)) { seen.add(kind + ":" + key); order.push({ id: key, kind }); } + } + } + if (row.message?.stop_reason === "end_turn") completed = time(row); + } + for (const entry of order) { + if (entry.kind !== "tool") { + const message = messages.get(entry.id)!; + if (entry.kind === "thinking" && message.thinking) item(entry.id + ":thinking", "reasoning", { status: completed ? "completed" : "incomplete", summary: [{ type: "summary_text", text: message.thinking }] }); + if (entry.kind === "text" && message.text) item(entry.id, "output_message", { id: entry.id, status: completed ? "completed" : cancellation ? "incomplete" : "in_progress", text: message.text }); + continue; + } + const call = toolCalls.get(entry.id)!; + const coordination = coordinationFact(call, child.id, children); + if (coordination) { item(call.id, "subagent_coordination", coordination); continue; } + if (call.name !== "Bash") continue; + if (typeof call.input.command !== "string") fail(); + const results = rows.flatMap(row => blocks(row)).filter(b => b.type === "tool_result" && b.tool_use_id === call.id); + if (results.length > 1 || (!results.length && !cancellation)) fail(); + if (!results.length) { + item(call.id, "tool_call", { id: call.id, stage: "after", observation: { kind: "command", status: "failed", command: call.input.command, output: "Native execution was cancelled." } }); + continue; + } + const result = results[0], content = result.content; + const output = typeof content === "string" ? content : Array.isArray(content) ? content.filter(b => b.type === "text").map(b => b.text).join("\n") : ""; + item(call.id, "tool_call", { id: call.id, stage: "after", observation: { kind: "command", status: result.is_error ? "failed" : "completed", command: call.input.command, output } }); + } + if (completed !== undefined && cancellation) fail(); + const ended = completed ?? cancellation?.completed_at_ms; + if (ended === undefined || ended < created) fail(); + facts.push({ type: "subagent_turn", fact: { ...state, status: cancellation ? "cancelled" : "completed", completed_at_ms: ended } }); + } + return facts; +} + +export async function readHistory(root: string, transcript: string, cwd: string, cancelled?: { at: number; admitted: ReadonlyMap }): Promise<{ facts: Fact[]; ids: string[] }> { + if (!identity.test(root) || !process.env.CLAUDE_CONFIG_DIR) fail(); + const state = await realpath(process.env.CLAUDE_CONFIG_DIR!); + const path = await realpath(transcript); + if (relative(state, path).startsWith("..") || !path.endsWith("/" + root + ".jsonl")) fail(); + async function read(path: string): Promise { + const resolved = await realpath(path); + if (relative(state, resolved).startsWith("..") || resolved !== path || (await stat(path)).size > 16 * 1024 * 1024) fail(); + return readFile(path, "utf8"); + } + async function rows(path: string): Promise { + const value = await read(path); + if (!value.endsWith("\n")) fail(); + const lines = value.trimEnd().split("\n"); + if (lines.length > 20000) fail(); + return lines.map(line => JSON.parse(line) as NativeRow); + } + const { getSubagentMessages, listSubagents } = await import("@anthropic-ai/claude-agent-sdk"); + const ids = await listSubagents(root, { dir: cwd }); + if (ids.length > 64 || new Set(ids).size !== ids.length) fail(); + const entries: Entry[] = []; + for (const id of ids) { + if (!identity.test(id)) fail(); + const base = join(dirname(path), root, "subagents", "agent-" + id); + const raw = await rows(base + ".jsonl"), metadata = JSON.parse(await read(base + ".meta.json")); + // The SDK resolves the persisted conversation chain; raw records retain the + // native ownership and timestamps omitted by its public TypeScript shape. + const messages = await getSubagentMessages(root, id, { dir: cwd }); + const own = new Map(raw.filter(r => r.uuid).map(r => [r.uuid, r])); + if (!messages.length || messages.some(m => !own.has(m.uuid))) fail(); + const selected = messages.map(m => own.get(m.uuid)!); + const cancellations: Cancellation[] = []; + const inputs = selected.filter(input); + for (let index = 0; index < inputs.length; index++) { + const own = inputs[index]; + if (!own.uuid || !identity.test(own.uuid)) fail(); + const receiptPath = base + ".cancel-" + own.uuid + ".json"; + let receipt: Cancellation | undefined; + try { receipt = JSON.parse(await read(receiptPath)); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const start = selected.indexOf(own), end = index + 1 < inputs.length ? selected.indexOf(inputs[index + 1]) : selected.length; + const finished = selected.slice(start, end).some(r => r.type === "assistant" && r.message?.stop_reason === "end_turn"); + if (!receipt && cancelled && index === inputs.length - 1 && !finished && cancelled.admitted.has(id)) { + receipt = { root, native_id: id, turn_id: own.uuid, source_item_id: metadata.toolUseId, last_item_id: selected[end - 1].uuid!, completed_at_ms: cancelled.at }; + await storeCancellation(receiptPath, receipt); + } + if (receipt) cancellations.push(receipt); + } + entries.push({ id, metadata, rows: selected, cancellations }); + } + return { ids, facts: projectHistory(root, await rows(path), entries) }; +} + +// Link publishes a complete receipt without replacing an earlier effect. Native +// history remains untouched; replay must use the first confirmed cancellation. +export async function storeCancellation(path: string, receipt: Cancellation): Promise { + const temporary = path + "." + randomUUID() + ".tmp"; + try { + await writeFile(temporary, JSON.stringify(receipt), { flag: "wx", mode: 0o600 }); + await link(temporary, path); + } finally { await unlink(temporary).catch(() => {}); } +} diff --git a/packages/claude-sdk-adapter/src/subagents.ts b/packages/claude-sdk-adapter/src/subagents.ts new file mode 100644 index 000000000..6e7522ac0 --- /dev/null +++ b/packages/claude-sdk-adapter/src/subagents.ts @@ -0,0 +1,94 @@ +import type { HookCallback, Options, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { readHistory, type Fact } from "./subagent_history.js"; + +const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } }); +export class Subagents { + readonly known = new Set(); + private readonly observedCalls = new Set(); + private readonly pending = new Map(); + private readonly running = new Map(); + private readonly admitted = new Map(); + private session = ""; + private transcript = ""; + private hydrated = false; + constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined) { + if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("invalid_request"); + } + expectSession(id: string | undefined): void { this.resume = id; } + permitsActor(id?: string): boolean { return id === undefined || this.known.has(id); } + isCoordination(name: string): boolean { return ["Agent", "Task", "SendMessage"].includes(name); } + readonly beforeTool: HookCallback = async (input, _id, { signal }) => { + if (input.hook_event_name !== "PreToolUse" || !this.isCoordination(input.tool_name)) return {}; + if (input.session_id === this.session && input.agent_id === undefined) { + this.transcript = input.transcript_path; + if (this.resume && !this.hydrated) { + for (const id of (await readHistory(this.session, this.transcript, this.cwd)).ids) this.known.add(id); + this.hydrated = true; + } + } + if (signal.aborted || input.session_id !== this.session || !this.permitsActor(input.agent_id)) return deny("Subagent identity is unavailable."); + const value = input.tool_input as Record; + if (!value || typeof value !== "object") return deny("Invalid subagent call."); + const spawn = input.tool_name !== "SendMessage"; + if (spawn ? value.subagent_type !== "parsar_worker" || typeof value.prompt !== "string" || !value.prompt.trim() || + value.isolation !== undefined || value.run_in_background === true || value.model !== undefined || value.mode !== undefined + : typeof value.to !== "string" || !this.known.has(value.to) || (value.type !== undefined && value.type !== "message") || + typeof value.message !== "string" || !value.message.trim()) return deny("Unsupported subagent operation."); + if (this.pending.has(input.tool_use_id)) return {}; + if (!spawn && (this.running.has(value.to as string) || [...this.pending.values()].includes(value.to as string))) return deny("The subagent is still running."); + { + if (this.pending.size + this.running.size >= this.limit) return deny("The concurrent subagent limit has been reached."); + this.pending.set(input.tool_use_id, spawn ? "" : value.to as string); + } + return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } }; + }; + readonly stopped: HookCallback = async input => { + if (input.hook_event_name === "Stop" && input.session_id === this.session && input.agent_id === undefined) this.transcript = input.transcript_path; + return {}; + }; + readonly childStart: HookCallback = async input => { + if (input.hook_event_name !== "SubagentStart" || input.session_id !== this.session || input.agent_type !== "parsar_worker" || !this.running.has(input.agent_id)) throw new Error("invalid native child start"); + this.known.add(input.agent_id); + return {}; + }; + readonly failedTool: HookCallback = async input => { + if (input.hook_event_name === "PostToolUseFailure") this.pending.delete(input.tool_use_id); + return {}; + }; + options(workspace: boolean): Pick { + return { forwardSubagentText: true, agents: { parsar_worker: { + description: "A subagent for delegated work in this Session.", prompt: "Complete the delegated task using the available tools.", model: "inherit", + tools: [...(workspace ? ["Bash"] : []), "Agent", "SendMessage"], + } } }; + } + consume(message: SDKMessage): void { + if (message.type === "assistant" && message.parent_tool_use_id === null) { + for (const block of message.message.content) if (block.type === "tool_use") this.observedCalls.add(block.id); + } + if (message.type === "system" && message.subtype === "init") { + if (!message.session_id || this.session && this.session !== message.session_id || this.resume && this.resume !== message.session_id) throw new Error("unexpected subagent root"); + this.session = message.session_id; + } + if (message.type === "system" && message.subtype === "task_started" && message.task_type === "local_agent") { + if (message.session_id !== this.session || !message.tool_use_id || !/^[A-Za-z0-9_-]{1,160}$/.test(message.task_id)) throw new Error("unbound native child task"); + const recipient = this.pending.get(message.tool_use_id); + if (recipient && recipient !== message.task_id) throw new Error("mismatched native continuation target"); + if (!this.pending.delete(message.tool_use_id) && !this.running.has(message.task_id)) throw new Error("unadmitted native child task"); + this.known.add(message.task_id); + this.running.set(message.task_id, message.tool_use_id); + this.admitted.set(message.task_id, message.tool_use_id); + } else if (message.type === "system" && message.subtype === "task_notification" && this.known.has(message.task_id)) { + if (this.running.get(message.task_id) !== message.tool_use_id) throw new Error("unmatched native child completion"); + this.running.delete(message.task_id); + } else if (message.type === "user" && Array.isArray(message.message.content)) { + for (const block of message.message.content) if (block.type === "tool_result") this.pending.delete(block.tool_use_id); + } + } + async facts(cancelledAt?: number): Promise { + if (!this.session || !this.transcript) { if (!this.known.size) return []; throw new Error("native child history is missing"); } + if (cancelledAt === undefined && (this.pending.size || this.running.size)) throw new Error("native child work is unsettled"); + const history = await readHistory(this.session, this.transcript, this.cwd, cancelledAt === undefined ? undefined : { at: cancelledAt, admitted: this.admitted }); + if ([...this.known].some(id => !history.ids.includes(id))) throw new Error("native child history is missing"); + return history.facts.filter(event => event.type !== "subagent_coordination" || this.observedCalls.has(event.fact.id as string)); + } +} diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index a431c15a6..8e5708379 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -1,3 +1,4 @@ +import type { Subagents } from "./subagents.js"; import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environment.js"; import type { MCPProfile } from "./mcp.js"; import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; @@ -81,7 +82,7 @@ export class WorkspaceProfile { readonly options: Options; private readonly skillNames: readonly string[]; - constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = [], private readonly mcp?: MCPProfile) { + constructor(private readonly cwd: string, private readonly config: Workspace, private readonly functions: readonly string[] = [], private readonly mcp?: MCPProfile, private readonly subagents?: Subagents) { config = parseWorkspace(config, cwd)!; // SDK history lookup reads the bridge environment, independently of query.env. if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || @@ -109,7 +110,7 @@ export class WorkspaceProfile { const skillTools = skills ? ["Skill"] : []; const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { - env, tools: [...nativeTools, ...skillTools], + env, tools: [...nativeTools, ...skillTools, ...(subagents ? ["Agent", "SendMessage"] : [])], ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: mcp?.allowed ?? [...functions], mcpServers: {}, strictMcpConfig: true, settingSources: [], permissionMode: "default", persistSession: true, settings: { @@ -140,10 +141,10 @@ export class WorkspaceProfile { verify(tools: string[], servers: { name: string; status: string; tools?: { name: string }[] }[], sessionID = ""): void { if (this.mcp) { - this.mcp.verify(tools, servers as Parameters[1], sessionID, [...nativeTools, ...(this.skillNames.length ? ["Skill"] : [])]); + this.mcp.verify(tools, servers as Parameters[1], sessionID, [...nativeTools, ...(this.skillNames.length ? ["Skill"] : []), ...(this.subagents ? ["Task", "SendMessage"] : [])]); return; } - const expected = [...nativeTools, ...this.functions, ...(this.skillNames.length ? ["Skill"] : [])]; + const expected = [...nativeTools, ...this.functions, ...(this.skillNames.length ? ["Skill"] : []), ...(this.subagents ? ["Task", "SendMessage"] : [])]; if (servers.length !== (this.functions.length ? 1 : 0) || servers.some(server => server.name !== "functions" || server.status !== "connected") || tools.length !== expected.length || new Set(tools).size !== tools.length || @@ -151,7 +152,7 @@ export class WorkspaceProfile { } readonly canUseTool: CanUseTool = async (name, input, { signal, agentID }) => { - if (!signal.aborted && agentID === undefined && this.permits(name, input)) { + if (!signal.aborted && (agentID === undefined || this.subagents?.permitsActor(agentID)) && this.permits(name, input)) { return { behavior: "allow", updatedInput: this.absoluteInput(name, input) }; } return { behavior: "deny", message: denial }; @@ -164,7 +165,7 @@ export class WorkspaceProfile { admission.hookSpecificOutput.permissionDecision === "deny") return admission; if (input.hook_event_name === "PreToolUse" && this.mcp.permits(input.tool_name)) return admission; } - if (!signal.aborted && input.hook_event_name === "PreToolUse" && input.agent_id === undefined && + if (!signal.aborted && input.hook_event_name === "PreToolUse" && (input.agent_id === undefined || this.subagents?.permitsActor(input.agent_id)) && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { if (input.tool_name === "Bash" && this.config.tool_environment) { const toolInput = input.tool_input as Record; diff --git a/packages/claude-sdk-adapter/tests/subagents.test.mjs b/packages/claude-sdk-adapter/tests/subagents.test.mjs new file mode 100644 index 000000000..cc8e9b0ec --- /dev/null +++ b/packages/claude-sdk-adapter/tests/subagents.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { projectHistory } from "../dist/subagent_history.js"; +import { Subagents } from "../dist/subagents.js"; + +const timestamp = n => new Date(1700000000000 + n).toISOString(); +function fixture() { + const root = [ + { type: "user", uuid: "root-turn", message: { content: "delegate" } }, + { type: "assistant", message: { content: [{ type: "tool_use", id: "spawn", name: "Agent", input: { prompt: "child input" } }] } }, + { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "spawn", content: "finished" }] }, toolUseResult: { agentId: "child" } }, + ]; + const rows = [ + { type: "user", uuid: "child-turn", parentUuid: null, timestamp: timestamp(10), message: { content: "child input" } }, + { type: "assistant", uuid: "thinking", timestamp: timestamp(20), message: { id: "answer", content: [{ type: "thinking", thinking: "reason" }] } }, + { type: "assistant", uuid: "text", timestamp: timestamp(30), message: { id: "answer", content: [{ type: "text", text: "result" }], stop_reason: "end_turn" } }, + ].map(r => ({ ...r, sessionId: "root", agentId: "child", isSidechain: true })); + return { root, children: [{ id: "child", metadata: { toolUseId: "spawn", spawnDepth: 1 }, rows }] }; +} +test("native child input owns its Turn and stable creation time; completion is not closure", () => { + const { root, children } = fixture(), facts = projectHistory("root", root, children); + assert.equal(facts[0].fact.native_created_at, 1700000000); + assert.equal(facts[0].fact.parent_turn_id, "root-turn"); + assert.equal(facts.filter(e => e.type === "subagent_turn").length, 2); + assert.deepEqual(facts.filter(e => e.type === "subagent_item").map(e => e.fact.kind), ["message", "reasoning", "output_message"]); + assert.equal(facts.find(e => e.type === "subagent_turn" && e.fact.status === "completed").fact.completed_at_ms, 1700000000030); + assert(!facts.some(e => e.type === "subagent_lifecycle")); + assert.deepEqual(facts, projectHistory("root", structuredClone(root), structuredClone(children))); +}); +test("resumed own input preserves original identity and creates a second child Turn", () => { + const { root, children } = fixture(), rows = children[0].rows; + rows.push({ ...rows[0], uuid: "continued", parentUuid: "text", isMeta: true, timestamp: timestamp(40), message: { content: "continue" } }, + { ...rows[2], uuid: "continued-output", timestamp: timestamp(50), message: { id: "continued-answer", content: [{ type: "text", text: "continued" }], stop_reason: "end_turn" } }); + const facts = projectHistory("root", root, children); + assert.deepEqual(facts.filter(e => e.type === "subagent_turn" && e.fact.status === "completed").map(e => e.fact.turn_id), ["child-turn", "continued"]); + assert.equal(facts[0].fact.native_created_at, 1700000000); +}); +test("missing parent evidence, inherited content, foreign ownership and incomplete work fail closed", () => { + for (const change of [ + f => { f.children[0].metadata.spawnDepth = 2; }, + f => { f.children[0].metadata.toolUseId = "missing"; }, + f => { f.children[0].rows[0].parentUuid = "inherited"; }, + f => { f.children[0].rows[1].agentId = "sibling"; }, + f => { f.children[0].rows[2].message.stop_reason = null; }, + f => { delete f.children[0].rows[0].timestamp; }, + ]) { const f = fixture(); change(f); assert.throws(() => projectHistory("root", f.root, f.children), /invalid native child history/); } +}); +test("native admission reserves before start across the whole tree and releases once", async () => { + const profile = new Subagents("/workspace", 1, undefined), context = { signal: new AbortController().signal }; + profile.consume({ type: "system", subtype: "init", session_id: "root" }); + const before = (id, actor) => profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "root", tool_name: "Agent", tool_use_id: id, agent_id: actor, + tool_input: { subagent_type: "parsar_worker", prompt: "work" } }, id, context); + assert.equal((await before("one")).hookSpecificOutput.permissionDecision, "allow"); + assert.equal((await before("two")).hookSpecificOutput.permissionDecision, "deny"); + profile.consume({ type: "system", subtype: "task_started", task_type: "local_agent", task_id: "child", session_id: "root", tool_use_id: "one" }); + await profile.childStart({ hook_event_name: "SubagentStart", session_id: "root", agent_id: "child", agent_type: "parsar_worker" }, undefined, context); + assert.equal((await before("nested", "child")).hookSpecificOutput.permissionDecision, "deny"); + const send = await profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "root", tool_name: "SendMessage", tool_use_id: "send", + tool_input: { to: "child", message: "continue" } }, "send", context); + assert.equal(send.hookSpecificOutput.permissionDecision, "deny"); + profile.consume({ type: "system", subtype: "task_notification", task_id: "child", session_id: "root", tool_use_id: "one", status: "completed" }); + assert.equal((await before("next", "child")).hookSpecificOutput.permissionDecision, "allow"); + assert.equal((await before("unknown", "foreign")).hookSpecificOutput.permissionDecision, "deny"); + await profile.failedTool({ hook_event_name: "PostToolUseFailure", tool_use_id: "next" }, undefined, context); + assert.equal((await before("final")).hookSpecificOutput.permissionDecision, "allow"); +}); + +test("an unadmitted native start cannot authorize a child tool actor", async () => { + const profile = new Subagents("/workspace", 1, undefined); + profile.consume({ type: "system", subtype: "init", session_id: "root" }); + await assert.rejects(profile.childStart({ hook_event_name: "SubagentStart", session_id: "root", agent_id: "foreign", agent_type: "parsar_worker" }), /invalid native child start/); + assert.equal(profile.permitsActor("foreign"), false); +}); + +test("confirmed cancellation retains own partial history and a stable effect time", () => { + const { root, children } = fixture(); + root.pop(); + children[0].rows.pop(); + children[0].cancellations = [{ root: "root", native_id: "child", turn_id: "child-turn", source_item_id: "spawn", last_item_id: "thinking", completed_at_ms: 1700000000100 }]; + const facts = projectHistory("root", root, children); + const terminal = facts.filter(e => e.type === "subagent_turn").at(-1).fact; + assert.equal(terminal.status, "cancelled"); + assert.equal(terminal.completed_at_ms, 1700000000100); + assert.equal(facts.at(-1).fact.status, "failed"); + assert.deepEqual(projectHistory("root", structuredClone(root), structuredClone(children)), facts); + children[0].rows.push({ ...children[0].rows[1], uuid: "late-recovery-record", message: { id: "late", content: [{ type: "text", text: "late native record" }], stop_reason: "end_turn" } }); + assert.deepEqual(projectHistory("root", root, children), facts); + children[0].cancellations[0].native_id = "foreign"; + assert.throws(() => projectHistory("root", root, children), /invalid native child history/); +}); + +test("cancellation publication is atomic and cannot replace an earlier effect", async () => { + const { mkdtemp, readFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { storeCancellation } = await import("../dist/subagent_history.js"); + const dir = await mkdtemp(join(tmpdir(), "claude-child-cancel-")), path = join(dir, "effect.json"); + const receipt = { root: "root", native_id: "child", turn_id: "turn", source_item_id: "spawn", last_item_id: "thinking", completed_at_ms: 1700000000100 }; + try { + await storeCancellation(path, receipt); + await assert.rejects(storeCancellation(path, { ...receipt, completed_at_ms: 1700000000200 }), { code: "EEXIST" }); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), receipt); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test("simultaneous continuations reserve the idle recipient before native task start", async () => { + const profile = new Subagents("/workspace", 6, undefined), context = { signal: new AbortController().signal }; + profile.consume({ type: "system", subtype: "init", session_id: "root" }); + profile.known.add("child"); + const send = id => profile.beforeTool({ hook_event_name: "PreToolUse", session_id: "root", tool_name: "SendMessage", tool_use_id: id, + tool_input: { to: "child", message: "continue" } }, id, context); + const result = await Promise.all([send("first"), send("second")]); + assert.deepEqual(result.map(r => r.hookSpecificOutput.permissionDecision), ["allow", "deny"]); + await profile.failedTool({ hook_event_name: "PostToolUseFailure", tool_use_id: "first" }); + assert.equal((await send("retry")).hookSpecificOutput.permissionDecision, "allow"); + assert.throws(() => profile.consume({ type: "system", subtype: "task_started", task_type: "local_agent", task_id: "foreign", session_id: "root", tool_use_id: "retry" }), /mismatched native continuation target/); +}); + +test("child input uses the shared neutral input envelope", () => { + const { root, children } = fixture(); + const input = projectHistory("root", root, children).find(e => e.type === "subagent_item" && e.fact.kind === "message"); + assert.deepEqual(input.fact.payload, { input: [{ role: "user", content: [{ type: "input_text", text: "child input" }] }] }); + assert.equal(input.fact.item_id, "child-turn"); + assert.equal(input.fact.position, 0); +}); diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 2c7872e01..13d1252dc 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -1,8 +1,12 @@ # MiniMax Code workspace bridge -This adapter companion keeps the published MiniMax Code CLI, ACP, model loop and -history. Its trusted MCP server exposes six original native tools inside the -upstream-vendored Linux sandbox. There is no CLI source patch or replacement loop. +This adapter companion keeps MiniMax Code ACP, model loop and history. Its trusted +MCP server exposes six original native tools inside the upstream-vendored Linux +sandbox. The pinned native CLI has one source patch: the original SQLite task +admission transaction enforces the daemon's Subagent concurrency limit before +child work starts. Foreground, background, nested and idle-child append admissions +share that transaction; terminal native tasks release capacity. No second model +or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. The harness process and ACP Session use a private control directory. Builtin file @@ -12,27 +16,48 @@ are read through sandboxed tools rather than imported by the privileged harness. Native diff/undo capture is not provided by this path. Common Files/Artifacts use the bound public workspace independently of the native control directory. -`source.json` pins the native tool and sandbox source. The published CLI is a -separate dependency; both artifacts require qualification. On Linux x86_64: +`source.json` pins the CLI, native tool and sandbox source. The pinned npm package +supplies native runtime dependencies; the CLI is built from source into the same +qualified artifact. On Linux x86_64: ```sh -MCODE_NATIVE_SOURCE=/absolute/upstream/checkout bash scripts/build-mcode-harness.sh +MCODE_NATIVE_SOURCE=/absolute/upstream/checkout MCODE_CLI_DIR=/absolute/pinned/package bash scripts/build-mcode-harness.sh ``` This standalone companion uses its own npm lock and is excluded from the root pnpm workspace. The build archives the exact source revision, bundles its native tools and sandbox -and installs pinned MCP dependencies. It does not build the upstream CLI. Install +and installs pinned MCP dependencies. The same source archive builds the CLI with +its upstream build script and lockfile; the pinned package supplies only native +runtime dependencies. `native-patch.json` records the upstream revision and exact +patch hashes. Runtime packaging uses this single CLI artifact. Install the artifact immutably at `/opt/mcode-harness`. The private profile supplies `workspace`, `scratch`, `protectedDirs` and `network`. Only the isolated worker receives the real workspace as its tool root. Missing or mismatched profiles reject; there is no unsandboxed fallback. +`subagent-snapshot.mjs` is a daemon-only reader of the private native SQLite +history. It opens a read-only transaction, scopes recursive descendants to the +bound root, and fails explicitly if a complete snapshot exceeds its bounds. +It never executes a model or exposes native history to workspace tools. The +adapter freezes root output and retains the same ACP owner for bounded child +settlement. A completed/idle task remains an active public Subagent; native abort +is a Turn cancellation and never implies a closed Subagent. + The bridge owns each launcher until exit. MCP cancellation and transport shutdown stop all owned workers before releasing the bridge. The outer Runtime owns the native process group. Both boundaries require real Docker cancellation tests. +The daemon sets the protected `protected-mcp-v1` tool policy independently of +the concurrency limit. The native catalog applies it to root and child profiles, +withholding direct native filesystem and process tools. The Session-private +`parsar_workspace` MCP server supplies the already authorized workspace tools to +workers. Other MCP servers retain their existing native selection rules. Native +Explore/Verifier profiles retain their stricter native capability ceiling. ACP +initialization reports the applied policy and admission limit; enabled Subagents +reject an unpatched CLI before accepting model input. + Native tool schemas are retained. Text and image results use standard MCP content; -video results reject explicitly. The published CLI may add task/skill utility tools; +video results reject explicitly. The pinned native CLI may add task/skill utility tools; qualification must inspect the actual inventory rather than assume exactly six. See [workspace qualification](../../contracts/agents-api/mcode-workspace-v1.md) for diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs index 4d2cd1b91..ea2d489a4 100644 --- a/packages/mcode-harness/check.mjs +++ b/packages/mcode-harness/check.mjs @@ -5,9 +5,13 @@ import { execFileSync } from 'node:child_process'; if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Linux x86_64 is required'); for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', - 'dist/sandbox.mjs', 'dist/vendor/seccomp/x64/apply-seccomp']) accessSync(new URL(file, import.meta.url)); + 'dist/sandbox.mjs', 'dist/vendor/seccomp/x64/apply-seccomp', 'subagent-snapshot.mjs', + 'native/cli.js', 'native-patch.json']) accessSync(new URL(file, import.meta.url)); for (const command of ['bwrap', 'socat', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); const pin = JSON.parse(readFileSync(new URL('source.json', import.meta.url))); +const patch = JSON.parse(readFileSync(new URL('native-patch.json', import.meta.url))); +if (patch.revision !== pin.revision || !/^[a-f0-9]{64}$/.test(patch.files?.['patch-native.mjs'] ?? '') || + !/^[a-f0-9]{64}$/.test(patch.files?.['native-subagent-admission.mjs'] ?? '')) throw new Error('Native admission patch provenance mismatch'); const tools = JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), '/workspace', '--describe'], { maxBuffer: 1024 * 1024 })); if (tools.map(t => t.name).sort().join(',') !== 'bash,edit,glob,grep,read,write') throw new Error('Native tool inventory mismatch'); diff --git a/packages/mcode-harness/native-subagent-admission.mjs b/packages/mcode-harness/native-subagent-admission.mjs new file mode 100644 index 000000000..f4196fd93 --- /dev/null +++ b/packages/mcode-harness/native-subagent-admission.mjs @@ -0,0 +1,19 @@ +// Called inside the original native task INSERT transaction, before child work +// is admitted. All descendants share the root Session's concurrency budget. +export function enforceSubagentAdmission(db, task, configured = process.env.PARSAR_MCODE_MAX_SUBAGENTS) { + if (task.kind !== 'subagent') return; + const limit = Number(configured); + if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('Native Subagent admission is disabled'); + const root = db.prepare(`WITH RECURSIVE ancestors(session_id,parent_session_id) AS ( + SELECT session_id,parent_session_id FROM local_runtime_sessions WHERE session_id=? + UNION SELECT s.session_id,s.parent_session_id FROM local_runtime_sessions s JOIN ancestors a ON s.session_id=a.parent_session_id + ) SELECT session_id FROM ancestors WHERE parent_session_id IS NULL`).get(task.ownerSessionId); + if (!root?.session_id) throw new Error('Native Subagent root ownership is unavailable'); + const row = db.prepare(`WITH RECURSIVE tree(session_id) AS ( + SELECT ? UNION SELECT s.session_id FROM local_runtime_sessions s JOIN tree t ON s.parent_session_id=t.session_id + ) SELECT count(DISTINCT coalesce(json_extract(b.record_json,'$.metadata.childSessionId'),b.task_id)) AS active + FROM local_runtime_background_tasks b JOIN tree t ON t.session_id=b.owner_session_id + LEFT JOIN local_runtime_turn_ingress i ON i.turn_id=json_extract(b.record_json,'$.metadata.subTurnId') + WHERE b.kind='subagent' AND (b.status IN ('queued','running','stopping') OR i.status='accepted')`).get(root.session_id); + if (!Number.isSafeInteger(row?.active) || row.active >= limit) throw new Error('Native Subagent concurrency limit reached'); +} diff --git a/packages/mcode-harness/patch-native.mjs b/packages/mcode-harness/patch-native.mjs new file mode 100644 index 000000000..e639ea631 --- /dev/null +++ b/packages/mcode-harness/patch-native.mjs @@ -0,0 +1,44 @@ +import { readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = process.env.MCODE_SOURCE; +const pin = JSON.parse(readFileSync(join(here, 'source.json'), 'utf8')); +if (!root || readFileSync(join(root, '.parsar-source-revision'), 'utf8').trim() !== pin.revision) + throw new Error('Native source revision mismatch'); +const target = join(root, 'packages/local-runtime/src/background-task/store.ts'); +let source = readFileSync(target, 'utf8'); +const before = source.slice(source.indexOf(' async create('), source.indexOf(' async get(')); +if (!before.includes('return this.withDb((db) => {') || !before.endsWith(' });\n }\n\n')) + throw new Error('Pinned native task admission source changed'); +const after = before.replace('return this.withDb((db) => {', 'return this.withDb((db) => runInImmediateTransaction(db, () => {') + .replace(' db.prepare(', ' enforceSubagentAdmission(db, task);\n db.prepare(') + .replace(' });\n }\n\n', ' }));\n }\n\n'); +source = "import { enforceSubagentAdmission } from './parsar-subagent-admission.mjs';\n" + source.replace(before, after); +writeFileSync(target, source); +const acpPath = join(root, 'packages/tui/src/acp/agent.ts'); +const acp = readFileSync(acpPath, 'utf8'); +const marker = " _meta: {\n 'minimax-code/extensions': {"; +if (acp.split(marker).length !== 2) throw new Error('Pinned native ACP initialization changed'); +writeFileSync(acpPath, acp.replace(marker, " _meta: {\n 'parsar/subagents': { version: 1, workspaceTools: process.env.PARSAR_MCODE_TOOL_POLICY ?? null, maxConcurrent: Number(process.env.PARSAR_MCODE_MAX_SUBAGENTS ?? 0) },\n 'minimax-code/extensions': {")); +const catalogPath = join(root, 'packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog.ts'); +const catalog = readFileSync(catalogPath, 'utf8'); +const gate = " if (!isFeatureEnabled(ceiling, toolName)) return false;"; +if (catalog.split(gate).length !== 2) throw new Error('Pinned native tool capability gate changed'); +const selectorGate = " if (!input.selector.allowsTool(input.tool.def.name, input.options.selectorAlias)) return false;\n if (!isMcpServerAllowed(input.selector, input.options)) return false;"; +if (catalog.split(selectorGate).length !== 2) throw new Error('Pinned native selector gate changed'); +const withWorkspace = catalog.replace(selectorGate, ` const protectedWorkspace = process.env.PARSAR_MCODE_TOOL_POLICY === 'protected-mcp-v1' && + input.source === 'configured' && input.options.mcp === true && input.options.serverName === 'parsar_workspace'; + if (!protectedWorkspace && !input.selector.allowsTool(input.tool.def.name, input.options.selectorAlias)) return false; + if (!protectedWorkspace && !isMcpServerAllowed(input.selector, input.options)) return false;`); +writeFileSync(catalogPath, withWorkspace.replace(gate, ` if (process.env.PARSAR_MCODE_TOOL_POLICY === 'protected-mcp-v1') { + if (source === 'builtin' && !DELEGATION_TOOL_NAMES.has(toolName) && !TASK_CONTROL_TOOL_NAMES.has(toolName)) return false; + if (source === 'builtin-matrix') return false; + } +${gate}`)); +copyFileSync(join(here, 'native-subagent-admission.mjs'), join(root, 'packages/local-runtime/src/background-task/parsar-subagent-admission.mjs')); +const digest = name => createHash('sha256').update(readFileSync(join(here, name))).digest('hex'); +writeFileSync(join(root, '.parsar-native-patch.json'), JSON.stringify({ revision: pin.revision, + files: { 'patch-native.mjs':digest('patch-native.mjs'), 'native-subagent-admission.mjs':digest('native-subagent-admission.mjs') } }, null, 2)+'\n'); diff --git a/packages/mcode-harness/subagent-snapshot.mjs b/packages/mcode-harness/subagent-snapshot.mjs new file mode 100644 index 000000000..451489daa --- /dev/null +++ b/packages/mcode-harness/subagent-snapshot.mjs @@ -0,0 +1,50 @@ +import { DatabaseSync } from 'node:sqlite'; +import { realpathSync, statSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// This entry point is invoked only by the daemon, outside the workspace sandbox. +// The schema is pinned by source.json; no native code or model is executed here. +export function readSubagentSnapshot(dataDir, rootSessionId) { + if (!isAbsolute(dataDir) || resolve(dataDir) !== dataDir || realpathSync(dataDir) !== dataDir || !rootSessionId) + throw new Error('Invalid private native Session binding'); + const path = join(dataDir, 'v2/sqlite/runtime-state.sqlite'); + if (realpathSync(path) !== path || !statSync(path).isFile()) throw new Error('Invalid native history path'); + const db = new DatabaseSync(path, { readOnly: true }); + try { + db.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=5000; BEGIN'); + const sessions = db.prepare(`WITH RECURSIVE tree(session_id) AS ( + SELECT session_id FROM local_runtime_sessions WHERE session_id=? + UNION SELECT s.session_id FROM local_runtime_sessions s JOIN tree t ON s.parent_session_id=t.session_id + ) SELECT s.session_id AS id,s.parent_session_id AS parent,s.created_at_ms AS createdAt, + s.agent_name AS name,s.session_kind AS kind,s.purpose,s.status,s.archived + FROM local_runtime_sessions s JOIN tree t ON s.session_id=t.session_id ORDER BY s.created_at_ms,s.session_id LIMIT 10001`).all(rootSessionId); + if (!sessions.some(s => s.id === rootSessionId)) throw new Error('Native root Session not found'); + if (sessions.length > 10000) throw new Error('Native Session snapshot exceeds complete-read bound'); + const turns = db.prepare(`SELECT turn_id AS id,status,accepted_at_ms AS createdAt,completed_at_ms AS completedAt + FROM local_runtime_turn_ingress WHERE session_id=? ORDER BY accepted_at_ms,turn_id LIMIT 100001`); + const messages = db.prepare(`SELECT msg_id AS id,turn_id AS turnId,role,created_at_ms AS createdAt,data_json AS data + FROM local_runtime_message_rows WHERE session_id=? ORDER BY created_at_ms,id LIMIT 100001`); + const tasks = db.prepare(`SELECT record_json AS data FROM local_runtime_background_tasks + WHERE owner_session_id=? AND kind='subagent' ORDER BY created_at_ms,task_id LIMIT 100001`); + for (const session of sessions) { + session.turns = turns.all(session.id); + session.messages = messages.all(session.id).map(row => ({ ...row, data: JSON.parse(row.data) })); + session.tasks = tasks.all(session.id).map(row => JSON.parse(row.data)); + if (session.turns.length > 100000 || session.messages.length > 100000 || session.tasks.length > 100000) + throw new Error('Native history snapshot exceeds complete-read bound'); + } + db.exec('COMMIT'); + return { version: 1, rootSessionId, complete: true, sessions }; + } finally { db.close(); } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.stdout.write(JSON.stringify(readSubagentSnapshot(process.argv[2], process.argv[3])) + '\n'); + } catch { + // Native history may contain private model input; never echo it in diagnostics. + process.stderr.write('MiniMax child history snapshot failed\n'); + process.exitCode = 1; + } +} diff --git a/packages/mcode-harness/subagents.test.mjs b/packages/mcode-harness/subagents.test.mjs new file mode 100644 index 000000000..a4c1e20a4 --- /dev/null +++ b/packages/mcode-harness/subagents.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { enforceSubagentAdmission } from './native-subagent-admission.mjs'; +import { readSubagentSnapshot } from './subagent-snapshot.mjs'; + +function schema(db) { + db.exec(`CREATE TABLE local_runtime_sessions(session_id TEXT PRIMARY KEY,parent_session_id TEXT,created_at_ms INTEGER,agent_name TEXT,session_kind TEXT,purpose TEXT,status TEXT,archived INTEGER); + CREATE TABLE local_runtime_background_tasks(task_id TEXT,owner_session_id TEXT,kind TEXT,status TEXT,created_at_ms INTEGER,record_json TEXT); + CREATE TABLE local_runtime_turn_ingress(turn_id TEXT,session_id TEXT,status TEXT,accepted_at_ms INTEGER,completed_at_ms INTEGER); + CREATE TABLE local_runtime_message_rows(id INTEGER PRIMARY KEY,session_id TEXT,msg_id TEXT,turn_id TEXT,role TEXT,created_at_ms INTEGER,data_json TEXT);`); + const add=db.prepare('INSERT INTO local_runtime_sessions VALUES(?,?,1000,\'worker\',\'task\',NULL,\'idle\',0)'); + add.run('root',null);add.run('child','root');add.run('nested','child');add.run('foreign',null); +} + +test('native admission counts nested work, isolates roots and reclaims terminal capacity', () => { + const db=new DatabaseSync(':memory:');schema(db); + const create=(id,owner,limit) => { + db.exec('BEGIN IMMEDIATE'); + try { + enforceSubagentAdmission(db,{kind:'subagent',ownerSessionId:owner},limit); + db.prepare('INSERT INTO local_runtime_background_tasks VALUES(?,?,\'subagent\',\'running\',1000,\'{}\')').run(id,owner); + db.exec('COMMIT'); + } catch(error) {db.exec('ROLLBACK');throw error;} + }; + try { + create('a','root',1); + assert.throws(()=>create('b','nested',1),/limit reached/); + create('foreign-task','foreign',1); + db.exec("UPDATE local_runtime_background_tasks SET status='canceled' WHERE task_id='a'"); + db.exec(`UPDATE local_runtime_background_tasks SET record_json='{"metadata":{"childSessionId":"child","subTurnId":"still-running"}}' WHERE task_id='a'; + INSERT INTO local_runtime_turn_ingress VALUES('still-running','child','accepted',1000,NULL)`); + assert.throws(()=>create('too-early','nested',1),/limit reached/); + db.exec("UPDATE local_runtime_turn_ingress SET status='aborted',completed_at_ms=1100 WHERE turn_id='still-running'"); + create('after-cancel','nested',1); + assert.throws(()=>create('disabled','root',0),/disabled/); + assert.throws(()=>create('unknown','missing',6),/ownership/); + assert.equal(db.prepare('SELECT count(*) AS n FROM local_runtime_background_tasks').get().n,3); + } finally {db.close();} +}); + +test('protected snapshots are complete, child-owned and stable after reopening', () => { + const dir=realpathSync(mkdtempSync(join(tmpdir(),'mcode-subagents-')));mkdirSync(join(dir,'v2/sqlite'),{recursive:true}); + const db=new DatabaseSync(join(dir,'v2/sqlite/runtime-state.sqlite'));schema(db); + db.exec("INSERT INTO local_runtime_turn_ingress VALUES('turn','child','completed',1100,1300)"); + db.prepare('INSERT INTO local_runtime_message_rows VALUES(1,?,?,?,?,?,?)').run('child','answer','turn','assistant',1200,JSON.stringify({msg_content:'answer'})); + db.prepare('INSERT INTO local_runtime_message_rows VALUES(2,?,?,?,?,?,?)').run('child','input','turn','user',1100,JSON.stringify({msg_content:'input'})); + db.close(); + try { + const first=readSubagentSnapshot(dir,'root'); + assert.equal(first.complete,true); + assert.deepEqual(first.sessions.map(s=>s.id).sort(),['child','nested','root']); + assert.deepEqual(first.sessions.find(s=>s.id==='child').messages.map(m=>m.id),['input','answer']); + assert.equal(first.sessions.find(s=>s.id==='root').messages.length,0); + assert.deepEqual(readSubagentSnapshot(dir,'root'),first); + assert.throws(()=>readSubagentSnapshot(dir,'missing'),/not found/); + symlinkSync(join(dir,'v2/sqlite/runtime-state.sqlite'),join(dir,'linked')); + assert.throws(()=>readSubagentSnapshot(join(dir,'linked'),'root')); + } finally {rmSync(dir,{recursive:true,force:true});} +}); diff --git a/scripts/agents-api-subagents-acceptance.py b/scripts/agents-api-subagents-acceptance.py new file mode 100644 index 000000000..7a5a4b885 --- /dev/null +++ b/scripts/agents-api-subagents-acceptance.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +"""Real-model acceptance of the six pinned Subagent GET operations. + +Install openai 3.13.0 from the commit in contracts/agents-api/upstream.json. +Required ENV: AGENTS_API_BASE_URL (including /v1), AGENTS_API_TOKEN, and +AGENTS_API_FOREIGN_TOKEN (a valid key for a different project). Creating a Session +also requires AGENTS_API_MODEL. AGENTS_API_ENVIRONMENT_JSON defaults to +{"type":"none"}; AGENTS_API_HARNESS optionally selects the existing Core extension. +AGENTS_API_SESSION_ID selects an existing Session. Close/resume use the native +conversation context from spawn; nullable names/instructions are not prerequisites. + +Run --phase all for the nested/close/resume native profile, --phase spawn-direct +for common reads with two real children, or spawn, inspect, close, resume using +the same --evidence directory under ~/.parsar. Inspect never submits model input. +For an independently prepared Session, inspect requires two real children and +proves reads only, not the full lifecycle. Use --require-nested when the native +profile supports nested delegation; absence is recorded, not fabricated. All/full acceptance requires observed spawn, nested, close and resume +facts; model prose is never accepted as evidence of a native action. + +Evidence contains only IDs, timestamps, counts and named checks. No HTTP bodies, +model output, API keys or provider configuration are written. Sessions are left +for the operator to inspect and clean up. This script starts no service/runtime. +""" + +import argparse +import importlib.metadata +import json +import os +from pathlib import Path +import re +import sys +import time +from urllib.parse import quote, urlsplit +import uuid + + +class AcceptanceFailure(Exception): + pass + + +def require(condition, code): + if not condition: + raise AcceptanceFailure(code) + + +def identifier(value): + require(isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,200}", value), "invalid_resource_id") + return value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--phase", choices=("spawn", "spawn-direct", "inspect", "close", "resume", "all"), default="inspect") + parser.add_argument("--evidence", required=True, type=Path) + parser.add_argument("--timeout", type=int, default=240) + parser.add_argument("--require-nested", action="store_true", help="Require native nested delegation in an externally prepared fixture") + args = parser.parse_args() + require(1 <= args.timeout <= 1800, "invalid_timeout") + evidence = args.evidence.expanduser().resolve() + require(Path.home().joinpath(".parsar") in evidence.parents, "evidence_must_be_under_parsar_home") + evidence.mkdir(parents=True, exist_ok=True, mode=0o700) + path = evidence / "subagents-proof.json" + report = json.loads(path.read_text()) if path.exists() else { + "passed": False, "checks": [], "phases": {}, "nonce": uuid.uuid4().hex, + } + token = os.environ.get("AGENTS_API_TOKEN", "") + foreign_token = os.environ.get("AGENTS_API_FOREIGN_TOKEN", "") + + def save(): + raw = json.dumps(report, indent=2) + "\n" + require(not any(secret and secret in raw for secret in (token, foreign_token)), "secret_in_evidence") + temporary = path.with_suffix(".tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as stream: + stream.write(raw) + os.replace(temporary, path) + + def checked(name): + if name not in report["checks"]: + report["checks"].append(name) + + client = foreign = http = None + try: + pin = json.loads((Path(__file__).resolve().parents[1] / "contracts/agents-api/upstream.json").read_text()) + distribution = importlib.metadata.distribution("openai") + source = json.loads(distribution.read_text("direct_url.json") or "{}") + require(distribution.version == pin["sdk_version"] == "3.13.0" and + source.get("vcs_info", {}).get("commit_id") == pin["commit"], "pinned_sdk_required") + report.update(sdk_version=pin["sdk_version"], sdk_commit=pin["commit"], passed=False) + base = os.environ.get("AGENTS_API_BASE_URL", "").rstrip("/") + parsed = urlsplit(base) + require(parsed.scheme in ("http", "https") and parsed.netloc and parsed.path.endswith("/v1") + and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment, + "invalid_base_url") + require(token and foreign_token and token != foreign_token, "distinct_project_tokens_required") + import httpx2 + from openai import OpenAI + client = OpenAI(base_url=base, api_key=token, max_retries=0, timeout=30, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) + foreign = OpenAI(base_url=base, api_key=foreign_token, max_retries=0, timeout=30, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False)) + http = httpx2.Client(trust_env=False, timeout=30) + # A bad credential cannot accidentally count as tenant-isolation proof. + foreign.beta.agents.sessions.list(limit=1) + sessions = client.beta.agents.sessions + supplied = os.environ.get("AGENTS_API_SESSION_ID") + if supplied: + require(not report.get("session_id") or report["session_id"] == supplied, "evidence_session_mismatch") + report["session_id"] = identifier(supplied) + headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} + other_headers = {**headers, "Authorization": "Bearer " + foreign_token} + + def sid(): + return identifier(report["session_id"]) + + def endpoint(suffix=""): + return base + "/agents/sessions/" + quote(sid(), safe="") + suffix + + def raw(suffix, params=None, expected=200, other=False): + response = http.get(endpoint(suffix), headers=other_headers if other else headers, params=params) + require(response.status_code == expected, "raw_status_" + str(expected) + "_expected") + if expected == 200: + return response.json() + body = response.json() + require(isinstance(body.get("error"), dict), "error_envelope_missing") + return None + + def sdk_list(resource, *pos, **keywords): + values, seen = [], set() + for item in resource.list(*pos, limit=100, order="asc", **keywords): + require(item.id not in seen, "sdk_cursor_repeated_resource") + seen.add(item.id) + values.append(item.to_dict()) + require(len(values) <= 5000, "history_bound_exceeded") + return values + + def all_turns(): + return sdk_list(sessions.turns, sid()) + + def children(): + return sdk_list(sessions.subagents, sid()) + + def wait_for(predicate, code, timeout=None): + deadline = time.monotonic() + (args.timeout if timeout is None else timeout) + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(1) + raise AcceptanceFailure(code) + + def root_finished(before): + turns = [turn for turn in all_turns() if turn.get("subagent_id") is None and turn["id"] not in before] + require(len(turns) <= 1, "concurrent_root_input_detected") + if not turns: + return False + turn = turns[0] + require(turn["status"] not in ("failed", "cancelled"), "root_execution_failed") + return turn if turn["status"] == "completed" else False + + def submit(stage, prompt): + require(report["phases"].get(stage) != "passed", "phase_already_passed") + require(not report.get("pending_phase"), "pending_phase_requires_operator_reconciliation") + before = {turn["id"] for turn in all_turns() if turn.get("subagent_id") is None} if report.get("session_id") else set() + report["pending_phase"] = stage + save() + if not report.get("session_id"): + model = os.environ.get("AGENTS_API_MODEL") + require(model, "model_required_for_creation") + agent = {"model": model, "multi_agent": {"enabled": True, "max_concurrent_subagents": 4}} + harness = os.environ.get("AGENTS_API_HARNESS") + if harness: + agent["x_agents_core"] = {"harness": harness} + environment = json.loads(os.environ.get("AGENTS_API_ENVIRONMENT_JSON", '{"type":"none"}')) + session = sessions.create(agent=agent, environment=environment, input=prompt, + extra_headers={"Idempotency-Key": report["nonce"] + "-" + stage}) + report["session_id"] = identifier(session.id) + save() + else: + require(sessions.retrieve(sid()).agent.multi_agent.enabled, "existing_session_delegation_disabled") + sessions.events.create(sid(), events=[{"type": "agent.session.input.message", "input": [ + {"role": "user", "content": [{"type": "input_text", "text": prompt}]}]}], + idempotency_key=report["nonce"] + "-" + stage) + turn = wait_for(lambda: root_finished(before), "root_turn_timeout") + report.setdefault("root_turns", {})[stage] = identifier(turn["id"]) + report.pop("pending_phase", None) + save() + + def page_check(suffix, expected): + expected_ids = [identifier(item["id"]) for item in expected] + require(len(expected_ids) == len(set(expected_ids)), "duplicate_resource_id") + for order in ("asc", "desc"): + for limit in (1, 2): + observed, after = [], None + while True: + params = {"order": order, "limit": limit} + if after: + params["after"] = after + page = raw(suffix, params) + require(isinstance(page.get("data"), list) and isinstance(page.get("has_more"), bool), "invalid_page_shape") + require(len(page["data"]) <= limit, "page_limit_not_enforced") + observed.extend(page["data"]) + require(len(observed) <= len(expected), "pagination_duplicate_or_unstable_history") + if not page["has_more"]: + break + require(page["data"] and page["data"][-1]["id"] != after, "cursor_did_not_advance") + after = page["data"][-1]["id"] + require(observed == (expected if order == "asc" else list(reversed(expected))), "sdk_raw_or_pagination_mismatch") + defaults = raw(suffix) + require(defaults["data"] == list(reversed(expected))[:20], "pagination_defaults_mismatch") + raw(suffix, {"after": str(uuid.uuid4())}, expected=404) + for params in ({"limit": 0}, {"limit": 101}, {"order": "invalid"}, {"unknown": "value"}): + raw(suffix, params, expected=400) + raw(suffix, other=True, expected=404) + + def inspect(): + subs = children() + require(subs, "fixture_not_observed_no_subagents") + page_check("/subagents", subs) + root_items = sdk_list(sessions.items, sid()) + turns = all_turns() + require(all("subagent_id" in turn for turn in turns), "turn_subagent_identity_field_missing") + by_turn = {turn["id"]: turn for turn in turns} + root_ids = {item["id"] for item in root_items} + require(all(by_turn.get(item["turn_id"], {}).get("subagent_id") is None and + item["turn_id"] in by_turn for item in root_items), "root_items_include_child_work") + known = {sub["id"] for sub in subs} + root = sessions.retrieve(sid()).agent.id + require(all(sub["session_id"] == sid() and sub["parent_agent_id"] in known | {root} for sub in subs), "subagent_parent_scope_mismatch") + parents = {sub["id"]: sub["parent_agent_id"] for sub in subs} + for child in parents: + lineage = set() + while child != root: + require(child not in lineage and child in parents, "invalid_subagent_parent_graph") + lineage.add(child) + child = parents[child] + nested = [sub["id"] for sub in subs if sub["parent_agent_id"] in known] + if args.require_nested or args.phase in ("spawn", "all"): + require(nested, "fixture_not_observed_no_nested_child") + require(len(subs) >= 2, "fixture_not_observed_two_children_for_scope_checks") + seen_items, summaries = set(root_ids), [] + for sub in subs: + child = identifier(sub["id"]) + suffix = "/subagents/" + child + fetched = sessions.subagents.retrieve(child, session_id=sid()).to_dict() + require(fetched == sub == raw(suffix), "subagent_retrieve_mismatch") + raw(suffix, other=True, expected=404) + child_turns = sdk_list(sessions.subagents.turns, child, session_id=sid()) + child_items = sdk_list(sessions.subagents.items, child, session_id=sid()) + require(child_turns and child_items, "fixture_not_observed_missing_child_history") + require(all(turn["status"] in ("completed", "failed", "cancelled") for turn in child_turns), "child_history_not_settled") + require(any(turn["status"] == "completed" for turn in child_turns), "fixture_not_observed_child_completion") + require(any(item.get("type") == "message" and item.get("role") == "assistant" and item.get("content") + for item in child_items), "fixture_not_observed_child_model_output") + page_check(suffix + "/turns", child_turns) + page_check(suffix + "/items", child_items) + own_turn_ids = {turn["id"] for turn in child_turns} + own_item_ids = {item["id"] for item in child_items} + require(not own_item_ids & seen_items and all(item["turn_id"] in own_turn_ids for item in child_items), "items_cross_agent_boundary") + seen_items |= own_item_ids + collected = set() + for turn in child_turns: + tid = identifier(turn["id"]) + require(turn["subagent_id"] == child and turn["agent_id"] == child and turn["session_id"] == sid(), "child_turn_owner_mismatch") + fetched = sessions.subagents.turns.retrieve(tid, session_id=sid(), subagent_id=child).to_dict() + require(fetched == turn == by_turn.get(tid) == sessions.turns.retrieve(tid, session_id=sid()).to_dict(), "session_child_turn_identity_mismatch") + require(raw(suffix + "/turns/" + tid) == turn, "raw_child_turn_mismatch") + raw(suffix + "/turns/" + tid, other=True, expected=404) + items = sdk_list(sessions.subagents.turns.items, tid, session_id=sid(), subagent_id=child) + page_check(suffix + "/turns/" + tid + "/items", items) + require(all(item["turn_id"] == tid for item in items), "turn_items_wrong_turn") + require(items == [item for item in child_items if item["turn_id"] == tid], "per_turn_items_mismatch") + collected.update(item["id"] for item in items) + require(collected == own_item_ids, "child_item_history_incomplete") + # A real root-owned cursor/Turn must not become valid in the child scope. + root_turn = next((turn["id"] for turn in turns if turn.get("subagent_id") is None), None) + require(root_turn, "fixture_not_observed_root_turn") + raw(suffix + "/turns/" + root_turn, expected=404) + raw(suffix + "/turns/" + root_turn + "/items", expected=404) + raw(suffix + "/turns", {"after": root_turn}, expected=404) + if root_items: + raw(suffix + "/items", {"after": root_items[0]["id"]}, expected=404) + summaries.append({"id": child, "parent_agent_id": identifier(sub["parent_agent_id"]), + "status": sub["status"], "opened_at": sub["opened_at"], "closed_at": sub["closed_at"], + "turn_ids": sorted(own_turn_ids), "item_ids": sorted(own_item_ids)}) + first, second = summaries[:2] + raw("/subagents/" + first["id"] + "/turns/" + second["turn_ids"][0], expected=404) + raw("/subagents/" + first["id"] + "/items", {"after": second["item_ids"][0]}, expected=404) + report.update(subagents=summaries, nested_ids=nested, root_item_count=len(root_items)) + for name in ("six_get_sdk_and_raw", "root_child_item_isolation", "session_child_turn_identity", + "pagination_asc_desc_after_limit", "invalid_and_wrong_scope_cursors", "foreign_project_scope"): + checked(name) + if nested: + checked("nested_parentage") + report["resources_passed"] = True + report["phases"]["inspect"] = "passed" + save() + + marker = "subagent-proof-" + report["nonce"][:12] + + def spawn(): + submit("spawn", "Use native subagent tools for this acceptance task; do not simulate delegation. " + f"Create two direct children. Give the first child the exact task marker {marker}-alpha " + f"in its task instructions. Ask it to create a nested child with marker {marker}-nested, " + "ask that nested child to compute 13 + 29, wait for its answer, then report the result. " + f"Give the second direct child task marker {marker}-beta and ask it to compute 7 * 8. " + "Wait for both direct children and report their results. Keep every child open and available; " + "do not close or interrupt them. Use no network or file tools.") + wait_for(lambda: len(children()) >= 3, "fixture_not_observed_spawn_and_nested", timeout=5) + root = sessions.retrieve(sid()).agent.id + direct = [sub for sub in children() if sub["parent_agent_id"] == root] + require(len(direct) >= 2, "fixture_not_observed_two_direct_children") + require(all(sub["status"] == "active" and sub["closed_at"] is None for sub in direct), "spawned_child_not_active") + inspect() + report["phases"]["spawn"] = "passed" + save() + + def spawn_direct(): + submit("spawn-direct", "Use your native subagent task tools; do not simulate delegation. " + f"Create two direct child sessions. Give the first marker {marker}-alpha " + "and ask it to compute 13 + 29. Give the second marker " + f"{marker}-beta and ask it to compute 7 * 8. " + "Wait for both native child tasks and report their results. " + "Do not create nested children, close or interrupt either child. " + "Use no network or file tools.") + wait_for(lambda: len(children()) >= 2, "fixture_not_observed_two_children", timeout=5) + inspect() + report["phases"]["spawn-direct"] = "passed" + save() + + def close_child(): + require(report["phases"].get("spawn") == "passed", "spawn_evidence_required") + root = sessions.retrieve(sid()).agent.id + before = {sub["id"]: sub for sub in children() if sub["parent_agent_id"] == root} + require(before and all(sub["status"] == "active" for sub in before.values()), "close_requires_active_children") + histories = {child: { + "turns": [turn["id"] for turn in sdk_list(sessions.subagents.turns, child, session_id=sid())], + "items": [item["id"] for item in sdk_list(sessions.subagents.items, child, session_id=sid())], + } for child in before} + submit("close", f"Use the native close tool to close only your existing direct child whose original task marker is {marker}-alpha. " + "Native processes may have been released between these root Turns. First use native resume on that SAME child ID " + "to load it if necessary, then invoke native close. Do not send a new task, create a replacement or merely interrupt it. " + "Leave the beta child open. Check the actual native close result and report failure if it failed.") + changed = wait_for(lambda: [sub for sub in children() if sub["id"] in before and sub["status"] == "closed"], + "fixture_not_observed_closed_child", timeout=5) + require(len(changed) == 1, "fixture_not_observed_single_closed_child") + closed = changed[0] + child = identifier(closed["id"]) + require(closed["opened_at"] == before[child]["opened_at"] and isinstance(closed["closed_at"], int), "closed_lifecycle_mismatch") + report.update(target_id=child, target_opened_at=closed["opened_at"], target_closed_at=closed["closed_at"], + before_close_turns=histories[child]["turns"], before_close_items=histories[child]["items"]) + inspect() + report["phases"]["close"] = "passed" + checked("close_preserves_identity_and_opened_at") + save() + + def resume_child(): + require(report["phases"].get("close") == "passed", "close_evidence_required") + require(sessions.subagents.retrieve(report["target_id"], session_id=sid()).status == "closed", "resume_requires_closed_child") + submit("resume", f"Use the native resume tool on the SAME closed child with original task marker {marker}-alpha. " + "Do not create a replacement. Send it a new task to compute 19 + 23, wait for its reply, " + "and leave it open and available. Report only after the native task finishes.") + resumed = wait_for(lambda: next((sub for sub in children() if sub["id"] == report["target_id"] and sub["status"] == "active"), None), + "fixture_not_observed_resumed_child", timeout=5) + require(resumed["opened_at"] == report["target_opened_at"] and resumed["closed_at"] is None, "resume_lifecycle_mismatch") + own_turns = sdk_list(sessions.subagents.turns, resumed["id"], session_id=sid()) + own_items = sdk_list(sessions.subagents.items, resumed["id"], session_id=sid()) + require(set(report["before_close_turns"]) < {turn["id"] for turn in own_turns}, "fixture_not_observed_new_resumed_turn") + require(any(turn["id"] not in report["before_close_turns"] and turn["status"] == "completed" for turn in own_turns), + "fixture_not_observed_resumed_turn_completion") + require(set(report["before_close_items"]) <= {item["id"] for item in own_items}, "resume_lost_item_history") + inspect() + report["phases"]["resume"] = "passed" + checked("resume_same_id_opened_at_null_closed_at_and_retained_history") + save() + + phases = {"spawn": spawn, "spawn-direct": spawn_direct, "inspect": inspect, "close": close_child, "resume": resume_child} + for phase in ("spawn", "close", "resume") if args.phase == "all" else (args.phase,): + phases[phase]() + report["passed"] = all(report["phases"].get(phase) == "passed" for phase in ("spawn", "inspect", "close", "resume")) + report["requested_phase_passed"] = True + report.pop("failure", None) + report.pop("http_status", None) + except Exception as error: + report.update(passed=False, requested_phase_passed=False, + failure=str(error) if isinstance(error, AcceptanceFailure) else type(error).__name__) + if isinstance(getattr(error, "status_code", None), int): + report["http_status"] = error.status_code + raise + finally: + for connection in (client, foreign, http): + if connection is not None: + connection.close() + save() + print(json.dumps({key: report.get(key) for key in ("passed", "resources_passed", "requested_phase_passed", "session_id", "target_id", "phases", "failure")})) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + # API exception messages may contain response bodies or expanded credentials. + print(json.dumps({"error": str(error) if isinstance(error, AcceptanceFailure) else type(error).__name__}), file=sys.stderr) + sys.exit(1) diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index e0c0a4776..ac57764aa 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" package="$repo_root/packages/mcode-harness" source="${MCODE_NATIVE_SOURCE:?Set MCODE_NATIVE_SOURCE to the pinned upstream checkout}" +native="${MCODE_CLI_DIR:?Set MCODE_CLI_DIR to the pinned CLI package supplying native dependencies}" if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then printf 'Build the MiniMax Code Runtime artifact on Linux x86_64\n' >&2 exit 1 @@ -21,6 +22,14 @@ mkdir "$context/upstream" git -C "$source" archive "$revision" | tar -x -C "$context/upstream" printf '%s\n' "$revision" > "$context/upstream/.parsar-source-revision" cp "$package/"*.mjs "$package/"*.ts "$package/"*.json "$context/" +test "$(node "$native/cli.js" --version)" = 0.4.12 +( + cd "$context" + MCODE_SOURCE="$context/upstream" node patch-native.mjs + cd upstream + corepack pnpm install --frozen-lockfile + node scripts/build.mjs +) ( cd "$context" npm ci --no-audit --no-fund @@ -33,6 +42,11 @@ artifact="$context/artifact" mkdir "$artifact" cp -R "$context/dist" "$context/node_modules" "$artifact/" cp "$package/launch.mjs" "$package/bridge.mjs" "$package/check.mjs" "$package/tool-executor.mjs" "$package/source.json" "$artifact/" +cp "$package/subagent-snapshot.mjs" "$artifact/" +mkdir "$artifact/native" +cp -R "$context/upstream/dist/." "$artifact/native/" +cp -R "$native/node_modules" "$artifact/native/" +cp "$context/upstream/.parsar-native-patch.json" "$artifact/native-patch.json" cp "$context/upstream/LICENSE" "$artifact/UPSTREAM_LICENSE" cp "$context/upstream/third_party/sandbox-runtime/LICENSE" "$artifact/SANDBOX_LICENSE" cp "$context/upstream/third_party/pi-mono/LICENSE" "$artifact/PI_LICENSE" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 54ededaad..21847f9dc 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -4,20 +4,20 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" runtime_root="${PARSAR_HOME:-$HOME/.parsar}" output="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/mcode-runtime}" -native="${MCODE_CLI_DIR:?Set MCODE_CLI_DIR to the installed published package directory}" companion="${MCODE_HARNESS_BUILD_DIR:?Set MCODE_HARNESS_BUILD_DIR to the built companion}" +native="$companion/native" helpers="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" for directory in "$runtime_root" "$output" "$native" "$companion" "$helpers"; do [[ "$directory" == /* ]] || { printf 'Absolute build directories are required\n' >&2; exit 1; } done test -f "$companion/provenance.json" +test -f "$companion/native-patch.json" test "$(node "$native/cli.js" --version)" = 0.4.12 mkdir -p "$runtime_root/cache/agents-runtime-builds" context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/mcode.XXXXXX")" trap 'rm -rf "$context"' EXIT -mkdir "$context/mcode" "$context/mcode-harness" -cp -RL "$native/." "$context/mcode/" -cp -R "$companion/." "$context/mcode-harness/" +mkdir "$context/mcode-harness" +cp -RL "$companion/." "$context/mcode-harness/" ( cd "$repo_root" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 4f4803635..6973ac16e 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 1); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index 6d8ec5a4b..757c2be80 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -91,7 +91,7 @@ func run() error { } var workerDone chan error var worker *execution.Worker - options := []api.Option{api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} + options := []api.Option{api.WithSubagents(executionStore), api.WithSkills(executionStore), api.WithSourceFiles(executionStore), api.WithSessionArtifacts(executionStore)} var daemonHandler http.Handler var registry *gateway.Registry if wsURL := os.Getenv("AGENTS_API_DAEMON_WS_URL"); wsURL != "" { diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile index 66e6f63af..17fc253dc 100644 --- a/services/agents-api/deploy/mcode/Dockerfile +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -1,4 +1,4 @@ -# Build context contains the daemon, shared helpers, published CLI and tool companion. +# Build context contains the daemon, shared helpers and pinned native artifact. FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 USER root RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -9,12 +9,11 @@ COPY build-system-seed.py /tmp/build-system-seed.py RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py COPY --chmod=0555 tool-root.py /usr/local/bin/agents-api-tool-root COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write agents-api-workspace-export /usr/local/bin/ -COPY mcode /opt/mcode COPY mcode-harness /opt/mcode-harness COPY --chmod=0444 runtime-initialize.py /usr/local/bin/agents-api-runtime-initialize ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ PARSAR_MCODE_NODE=/usr/local/bin/node \ - PARSAR_MCODE_BIN=/opt/mcode/cli.js \ + PARSAR_MCODE_BIN=/opt/mcode-harness/native/cli.js \ PARSAR_MCODE_WORKSPACE_BRIDGE=/opt/mcode-harness/bridge.mjs \ PARSAR_MCODE_AGENTS_API=1 \ PARSAR_MCODE_WORKSPACE=managed \ @@ -24,7 +23,7 @@ ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ PARSAR_RUNTIME_EXPORT_HELPER=/usr/local/bin/agents-api-workspace-export \ PARSAR_RUNTIME_STAGING=/environment/staging USER 1000:1000 -RUN node /opt/mcode-harness/check.mjs && /opt/mcode/cli.js --version +RUN node /opt/mcode-harness/check.mjs && /opt/mcode-harness/native/cli.js --version WORKDIR /environment/workspace ENTRYPOINT ["/usr/local/bin/parsar-daemon"] CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md index e48ef640d..f2cdcec4b 100644 --- a/services/agents-api/deploy/mcode/README.md +++ b/services/agents-api/deploy/mcode/README.md @@ -27,12 +27,13 @@ retain their engine. Do not expose a new public harness selector. ## Docker workspace -Build the shared workspace helpers, install the published CLI into a private -operator directory, and build the companion from the pinned native source: +Build the shared workspace helpers and the single CLI/companion artifact from +the pinned native source. Supply the matching npm package only for its native +runtime dependencies: ```sh -MCODE_NATIVE_SOURCE=/absolute/minimax-code bash scripts/build-mcode-harness.sh -MCODE_CLI_DIR=/absolute/published-package \ +MCODE_NATIVE_SOURCE=/absolute/minimax-code \ +MCODE_CLI_DIR=/absolute/pinned-package bash scripts/build-mcode-harness.sh MCODE_HARNESS_BUILD_DIR=/absolute/built-companion \ bash scripts/build-mcode-runtime.sh docker build --platform linux/amd64 -t agents-runtime:mcode \ diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 3ef02bd08..7d0622ea0 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -48,6 +48,7 @@ type Handler struct { skills SkillStore sourceFiles SourceFileStore artifacts SessionArtifactStore + subagents SubagentStore } func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ...Option) (http.Handler, error) { @@ -106,6 +107,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... r.Get("/agents/sessions/{session_id}/items", h.listItems) r.Get("/agents/sessions/{session_id}/turns", h.listTurns) r.Get("/agents/sessions/{session_id}/turns/{turn_id}", h.getTurn) + h.registerSubagentRoutes(r) r.Get("/agents/sessions/{session_id}/artifacts", h.listSessionArtifacts) r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}", h.getSessionArtifact) r.Get("/agents/sessions/{session_id}/artifacts/{artifact_id}/content", h.sessionArtifactContent) @@ -122,7 +124,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_agent.go b/services/agents-api/internal/api/session_agent.go index bb22ebb85..dfdb76906 100644 --- a/services/agents-api/internal/api/session_agent.go +++ b/services/agents-api/internal/api/session_agent.go @@ -64,9 +64,6 @@ func admitSessionAgent(cfg v1.SavedAgentConfiguration) (v1.Agent, error) { if strings.TrimSpace(cfg.Model) == "" { return v1.Agent{}, errors.New("Execution currently requires a nonempty model.") } - if cfg.MultiAgent.Enabled || cfg.MultiAgent.MaxConcurrentSubagents != nil { - return v1.Agent{}, errors.New("Enabled multi_agent execution is not supported by this service yet.") - } if cfg.Reasoning.Effort != nil || cfg.Reasoning.Summary != nil { return v1.Agent{}, errors.New("Explicit reasoning execution options are not supported by this service yet.") } diff --git a/services/agents-api/internal/api/subagent_turns.go b/services/agents-api/internal/api/subagent_turns.go new file mode 100644 index 000000000..23f0433be --- /dev/null +++ b/services/agents-api/internal/api/subagent_turns.go @@ -0,0 +1,97 @@ +package api + +import ( + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// @Summary Retrieve a Subagent Turn +// @Description Returns the same canonical Turn exposed by the Session Turn endpoint, scoped to the owning Subagent. Unknown or inaccessible parent scopes return not found. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Param turn_id path string true "Turn ID" +// @Success 200 {object} v1.Turn +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id} [get] +func (h *Handler) getSubagentTurn(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Subagent Turn retrieval does not accept query parameters.") + return + } + if !h.subagentsReady(w) { + return + } + value, err := h.subagents.GetSubagentTurn(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), chi.URLParam(r, "turn_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, value) +} + +// @Summary List a Subagent's Turns +// @Description Includes this Subagent's Turns after resume. Cursors belong to the same tenant, Session and Subagent. Missing recorded usage remains null. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Param after query string false "Last Turn ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Creation order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.TurnList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns [get] +func (h *Handler) listSubagentTurns(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagentTurns(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []v1.Turn{} + } + writeJSON(w, http.StatusOK, page) +} + +// @Summary List a Subagent Turn's Items +// @Description Returns Items owned by this exact Subagent Turn. Cursors belong to the same tenant, Session, Subagent and Turn. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Param turn_id path string true "Turn ID" +// @Param after query string false "Last Item ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.ItemList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/turns/{turn_id}/items [get] +func (h *Handler) listSubagentTurnItems(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagentTurnItems(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), chi.URLParam(r, "turn_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []v1.Item{} + } + writeJSON(w, http.StatusOK, page) +} diff --git a/services/agents-api/internal/api/subagents.go b/services/agents-api/internal/api/subagents.go new file mode 100644 index 000000000..9721d20dd --- /dev/null +++ b/services/agents-api/internal/api/subagents.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// SubagentStore reads tenant-authorized, persisted public resources. Implementations +// must enforce every supplied parent scope, including the pagination cursor. +type SubagentStore interface { + GetSubagent(context.Context, string, string, string) (v1.Subagent, error) + ListSubagents(context.Context, string, string, string, int, bool) (v1.SubagentList, error) + ListSubagentItems(context.Context, string, string, string, string, int, bool) (v1.ItemList, error) + GetSubagentTurn(context.Context, string, string, string, string) (v1.Turn, error) + ListSubagentTurns(context.Context, string, string, string, string, int, bool) (v1.TurnList, error) + ListSubagentTurnItems(context.Context, string, string, string, string, string, int, bool) (v1.ItemList, error) +} + +func WithSubagents(s SubagentStore) Option { return func(h *Handler) { h.subagents = s } } + +func (h *Handler) registerSubagentRoutes(r chi.Router) { + const root = "/agents/sessions/{session_id}/subagents" + r.Get(root, h.listSubagents) + r.Get(root+"/{subagent_id}", h.getSubagent) + r.Get(root+"/{subagent_id}/items", h.listSubagentItems) + r.Get(root+"/{subagent_id}/turns", h.listSubagentTurns) + r.Get(root+"/{subagent_id}/turns/{turn_id}", h.getSubagentTurn) + r.Get(root+"/{subagent_id}/turns/{turn_id}/items", h.listSubagentTurnItems) +} + +func (h *Handler) subagentsReady(w http.ResponseWriter) bool { + if h.subagents == nil { + writeError(w, http.StatusServiceUnavailable, "subagent_storage_unavailable", "Subagent storage is unavailable.") + return false + } + return true +} + +// @Summary Retrieve a Session Subagent +// @Description Returns this Session's persisted Subagent. Active includes idle between Turns. Resuming preserves opened_at and clears closed_at. Unknown or inaccessible parent scopes return not found. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Success 200 {object} v1.Subagent +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id} [get] +func (h *Handler) getSubagent(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Subagent retrieval does not accept query parameters.") + return + } + if !h.subagentsReady(w) { + return + } + value, err := h.subagents.GetSubagent(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, value) +} + +// @Summary List Session Subagents +// @Description Includes nested and closed Subagents. Cursors belong to the same tenant and Session. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param after query string false "Last Subagent ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.SubagentList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents [get] +func (h *Handler) listSubagents(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagents(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []v1.Subagent{} + } + writeJSON(w, http.StatusOK, page) +} + +// @Summary List a Subagent's Items +// @Description Returns only this Subagent's own Items across all its Turns, not its descendants' Items. Cursors belong to the same tenant, Session and Subagent. +// @Tags Subagents +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Param subagent_id path string true "Subagent ID" +// @Param after query string false "Last Item ID from the previous page" +// @Param limit query int false "Page size" minimum(1) maximum(100) default(20) +// @Param order query string false "Resource order" Enums(asc,desc) default(desc) +// @Success 200 {object} v1.ItemList +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/subagents/{subagent_id}/items [get] +func (h *Handler) listSubagentItems(w http.ResponseWriter, r *http.Request) { + options, ok := readPage(w, r) + if !ok || !h.subagentsReady(w) { + return + } + page, err := h.subagents.ListSubagentItems(r.Context(), tenantID(r), chi.URLParam(r, "session_id"), chi.URLParam(r, "subagent_id"), options.after, options.limit, options.ascending) + if err != nil { + writeStoreError(w, r, err) + return + } + if page.Data == nil { + page.Data = []v1.Item{} + } + writeJSON(w, http.StatusOK, page) +} diff --git a/services/agents-api/internal/api/subagents_test.go b/services/agents-api/internal/api/subagents_test.go new file mode 100644 index 000000000..6378d7d25 --- /dev/null +++ b/services/agents-api/internal/api/subagents_test.go @@ -0,0 +1,230 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +type subagentReadStore struct { + method, tenant, session, subagent, turn, after string + limit int + ascending bool + calls int + empty bool + err error +} + +func (s *subagentReadStore) record(method, tenant, session, subagent, turn, after string, limit int, ascending bool) { + s.method, s.tenant, s.session, s.subagent, s.turn, s.after = method, tenant, session, subagent, turn, after + s.limit, s.ascending = limit, ascending + s.calls++ +} + +func sampleSubagent() v1.Subagent { + return v1.Subagent{ID: "child", Object: "agent.session.subagent", SessionID: "session", ParentAgentID: "root", OpenedAt: 1700000000, Status: "active"} +} + +func sampleSubagentTurn() v1.Turn { + id := "child" + return v1.Turn{ID: "turn", AgentID: id, SubagentID: &id, SessionID: "session", Object: "agent.session.turn", Status: "completed", CreatedAt: 1700000001} +} + +func (s *subagentReadStore) GetSubagent(_ context.Context, tenant, session, subagent string) (v1.Subagent, error) { + s.record("get", tenant, session, subagent, "", "", 0, false) + return sampleSubagent(), s.err +} + +func (s *subagentReadStore) ListSubagents(_ context.Context, tenant, session, after string, limit int, asc bool) (v1.SubagentList, error) { + s.record("list", tenant, session, "", "", after, limit, asc) + if s.empty { + return v1.SubagentList{}, s.err + } + return v1.SubagentList{Data: []v1.Subagent{sampleSubagent()}, HasMore: true}, s.err +} + +func (s *subagentReadStore) ListSubagentItems(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.ItemList, error) { + s.record("items", tenant, session, subagent, "", after, limit, asc) + return s.items(), s.err +} + +func (s *subagentReadStore) GetSubagentTurn(_ context.Context, tenant, session, subagent, turn string) (v1.Turn, error) { + s.record("turn", tenant, session, subagent, turn, "", 0, false) + return sampleSubagentTurn(), s.err +} + +func (s *subagentReadStore) ListSubagentTurns(_ context.Context, tenant, session, subagent, after string, limit int, asc bool) (v1.TurnList, error) { + s.record("turns", tenant, session, subagent, "", after, limit, asc) + if s.empty { + return v1.TurnList{}, s.err + } + return v1.TurnList{Data: []v1.Turn{sampleSubagentTurn()}, HasMore: true}, s.err +} + +func (s *subagentReadStore) ListSubagentTurnItems(_ context.Context, tenant, session, subagent, turn, after string, limit int, asc bool) (v1.ItemList, error) { + s.record("turn_items", tenant, session, subagent, turn, after, limit, asc) + return s.items(), s.err +} + +func (s *subagentReadStore) items() v1.ItemList { + if s.empty { + return v1.ItemList{} + } + text := "Child result." + return v1.ItemList{Data: []v1.Item{{ID: "item", TurnID: "turn", Type: "agent_message", SenderAgentID: "child", RecipientAgentID: "root", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}}}, HasMore: true} +} + +var subagentRoutes = []struct { + path, method, subagent, turn string + list bool +}{ + {"", "list", "", "", true}, + {"/child", "get", "child", "", false}, + {"/child/items", "items", "child", "", true}, + {"/child/turns", "turns", "child", "", true}, + {"/child/turns/turn", "turn", "child", "turn", false}, + {"/child/turns/turn/items", "turn_items", "child", "turn", true}, +} + +func requestSubagents(h http.Handler, path, auth, beta string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/session/subagents"+path, nil) + r.Header.Set("Authorization", auth) + r.Header.Set("OpenAI-Beta", beta) + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestSubagentRoutesPreserveAuthenticatedParentScope(t *testing.T) { + s := &subagentReadStore{} + h, _, tenant := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + t.Run(route.method, func(t *testing.T) { + w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.method != route.method || s.tenant != tenant || s.session != "session" || s.subagent != route.subagent || s.turn != route.turn { + t.Fatalf("scope: %d %s; call=%+v", w.Code, w.Body, s) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if route.list { + if s.limit != 20 || s.ascending || s.after != "" || string(body["has_more"]) != "true" { + t.Fatalf("list defaults: %+v %s", s, w.Body) + } + w = requestSubagents(h, route.path+"?after=last&limit=2&order=asc", "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || s.after != "last" || s.limit != 2 || !s.ascending { + t.Fatalf("pagination: %+v %s", s, w.Body) + } + } else if route.method == "get" { + for _, field := range []string{"closed_at", "name", "instructions"} { + if string(body[field]) != "null" { + t.Fatalf("%s missing null: %s", field, w.Body) + } + } + } else if string(body["agent_id"]) != `"child"` || string(body["subagent_id"]) != `"child"` || string(body["usage"]) != "null" { + t.Fatalf("child Turn identity: %s", w.Body) + } + }) + } +} + +func TestSubagentRoutesRejectInvalidQueriesBeforeStore(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + queries := []string{"unknown=1", "tenant_id=foreign"} + if route.list { + queries = append(queries, "limit=0", "limit=101", "limit=null", "limit=2&limit=3", "order=random", "order=asc&order=desc", "after=a&after=b") + } else { + queries = append(queries, "limit=1", "after=a", "order=asc") + } + for _, query := range queries { + calls := s.calls + w := requestSubagents(h, route.path+"?"+query, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusBadRequest || s.calls != calls { + t.Fatalf("accepted %s?%s: %d %s", route.path, query, w.Code, w.Body) + } + } + } +} + +func TestSubagentRoutesUseExistingAuthenticationAndErrors(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + for _, tc := range []struct { + auth, beta string + status int + }{ + {"", "agents=v1", 401}, + {"Bearer wrong", "agents=v1", 401}, + {"Bearer test-api-key", "", 400}, + {"Bearer test-api-key", "agents=v2", 400}, + } { + calls := s.calls + w := requestSubagents(h, route.path, tc.auth, tc.beta) + if w.Code != tc.status || s.calls != calls { + t.Fatalf("authentication %s: %d %s", route.path, w.Code, w.Body) + } + } + for _, tc := range []struct { + err error + status int + }{ + {store.ErrNotFound, 404}, + {store.ErrInvalidInput, 400}, + {errors.New("SECRET native failure"), 500}, + } { + s.err = tc.err + w := requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != tc.status || strings.Contains(w.Body.String(), "SECRET") { + t.Fatalf("error %s: %d %s", route.path, w.Code, w.Body) + } + } + s.err = nil + } +} + +func TestSubagentRoutesDistinguishEmptyFromUnavailable(t *testing.T) { + s := &subagentReadStore{empty: true} + h, _, _ := testHandler(t, WithSubagents(s)) + unavailable, _, _ := testHandler(t) + for _, route := range subagentRoutes { + w := requestSubagents(unavailable, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("unwired store fabricated success: %d %s", w.Code, w.Body) + } + if route.list { + w = requestSubagents(h, route.path, "Bearer test-api-key", "agents=v1") + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"data":[],"has_more":false}` { + t.Fatalf("empty page: %d %s", w.Code, w.Body) + } + } + } +} + +func TestSubagentRoutesExposeOnlyOfficialReads(t *testing.T) { + s := &subagentReadStore{} + h, _, _ := testHandler(t, WithSubagents(s)) + for _, route := range subagentRoutes { + for _, method := range []string{http.MethodPost, http.MethodPatch, http.MethodDelete} { + r := httptest.NewRequest(method, "/v1/agents/sessions/session/subagents"+route.path, nil) + r.Header.Set("Authorization", "Bearer test-api-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusMethodNotAllowed || s.calls != 0 { + t.Fatalf("unexpected mutation route %s %s: %d %s", method, route.path, w.Code, w.Body) + } + } + } +} diff --git a/services/agents-api/internal/api/turns.go b/services/agents-api/internal/api/turns.go index 5b27c59e7..8f2227c18 100644 --- a/services/agents-api/internal/api/turns.go +++ b/services/agents-api/internal/api/turns.go @@ -92,6 +92,10 @@ func turnResponse(session store.Session, turn store.Turn) (v1.Turn, error) { return v1.Turn{}, errors.New("missing stored agent identity") } response := v1.Turn{Usage: tokenUsage(turn.Usage), ID: turn.ID, SessionID: turn.SessionID, AgentID: cfg.Agent.ID, Object: "agent.session.turn", Status: turn.Status, CreatedAt: turn.CreatedAt.Unix(), StartedAt: unixTime(turn.StartedAt), CompletedAt: unixTime(turn.CompletedAt)} + if turn.SubagentID != "" { + response.AgentID = turn.SubagentID + response.SubagentID = &turn.SubagentID + } if turn.Status == store.TurnFailed { // Native errors can contain secrets; publish a stable category without raw diagnostics. response.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} diff --git a/services/agents-api/internal/db/queries/subagent_identities.sql b/services/agents-api/internal/db/queries/subagent_identities.sql index ab5044195..40d481bd7 100644 --- a/services/agents-api/internal/db/queries/subagent_identities.sql +++ b/services/agents-api/internal/db/queries/subagent_identities.sql @@ -7,11 +7,14 @@ SELECT sqlc.arg(id), s.id, b.device_id, s.engine, sqlc.arg(native_id), sqlc.arg(parent_native_id), sqlc.arg(native_created_at), sqlc.arg(first_turn_id), sqlc.arg(first_event_ordinal) FROM sessions s JOIN session_devices b ON b.session_id = s.id WHERE s.id = sqlc.arg(session_id) - AND (b.native_session_id = '' OR b.native_session_id = sqlc.arg(parent_native_id)) - AND NOT EXISTS ( - SELECT 1 FROM subagent_identities old - WHERE old.session_id = s.id AND old.parent_native_id <> sqlc.arg(parent_native_id) - ) + AND (b.native_session_id = sqlc.arg(parent_native_id) + OR EXISTS (SELECT 1 FROM subagent_identities p + WHERE p.session_id = s.id AND p.native_id = sqlc.arg(parent_native_id)) + OR (b.native_session_id = '' AND NOT EXISTS + (SELECT 1 FROM subagent_identities old WHERE old.session_id = s.id + AND old.parent_native_id <> sqlc.arg(parent_native_id) + AND NOT EXISTS (SELECT 1 FROM subagent_identities ancestor + WHERE ancestor.session_id = s.id AND ancestor.native_id = old.parent_native_id)))) ON CONFLICT (device_id, engine, native_id) DO UPDATE SET id = subagent_identities.id WHERE subagent_identities.session_id = EXCLUDED.session_id AND subagent_identities.parent_native_id = EXCLUDED.parent_native_id diff --git a/services/agents-api/internal/db/queries/subagent_resources.sql b/services/agents-api/internal/db/queries/subagent_resources.sql new file mode 100644 index 000000000..cc59786d7 --- /dev/null +++ b/services/agents-api/internal/db/queries/subagent_resources.sql @@ -0,0 +1,91 @@ +-- name: GetPublicSubagent :one +SELECT i.*, COALESCE(p.id::text, s.configuration->'agent'->>'id')::text AS parent_agent_id +FROM subagent_identities i JOIN sessions s ON s.id = i.session_id +LEFT JOIN subagent_identities p ON p.session_id = i.session_id AND p.native_id = i.parent_native_id +WHERE i.session_id = $1 AND i.id = $2 AND i.public_visible; + +-- name: ListPublicSubagents :many +SELECT i.id FROM subagent_identities i +WHERE i.session_id = sqlc.arg(session_id) AND i.public_visible + AND (sqlc.narg(after_opened)::bigint IS NULL + OR (sqlc.arg(ascending)::boolean AND (i.native_created_at, i.id) > (sqlc.narg(after_opened)::bigint, sqlc.arg(after_id)::uuid)) + OR (NOT sqlc.arg(ascending)::boolean AND (i.native_created_at, i.id) < (sqlc.narg(after_opened)::bigint, sqlc.arg(after_id)::uuid))) +ORDER BY CASE WHEN sqlc.arg(ascending)::boolean THEN i.native_created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.native_created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: GetNativeSubagent :one +SELECT * FROM subagent_identities WHERE session_id = $1 AND native_id = $2; + +-- name: PublishSubagent :exec +UPDATE subagent_identities SET public_visible = true, name = $3, instructions = $4 WHERE session_id = $1 AND id = $2; + +-- name: PutSubagentEffect :one +INSERT INTO subagent_effects (session_id, effect_id, payload) VALUES ($1, $2, $3) +ON CONFLICT (session_id, effect_id) DO UPDATE SET effect_id = subagent_effects.effect_id +WHERE subagent_effects.payload = EXCLUDED.payload +RETURNING (xmax = 0)::boolean AS inserted; + +-- name: ApplySubagentLifecycle :exec +UPDATE subagent_identities SET status = $3, closed_at_ms = $4, lifecycle_at_ms = $5 +WHERE session_id = $1 AND id = $2; + +-- name: GetChildTurn :one +SELECT * FROM subagent_turns WHERE session_id = $1 AND id = $2; + +-- name: PutChildTurn :one +INSERT INTO subagent_turns (id, session_id, subagent_id, native_id, status, created_at, started_at, completed_at, token_usage) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, started_at = EXCLUDED.started_at, + completed_at = EXCLUDED.completed_at, token_usage = EXCLUDED.token_usage +WHERE subagent_turns.session_id = EXCLUDED.session_id AND subagent_turns.subagent_id = EXCLUDED.subagent_id + AND subagent_turns.native_id = EXCLUDED.native_id AND subagent_turns.created_at = EXCLUDED.created_at +RETURNING *; + +-- name: ListChildTurns :many +SELECT * FROM subagent_turns t +WHERE t.session_id = sqlc.arg(session_id) AND t.subagent_id = sqlc.arg(subagent_id) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (sqlc.arg(ascending)::boolean AND (t.created_at, t.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) + OR (NOT sqlc.arg(ascending)::boolean AND (t.created_at, t.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid))) +ORDER BY CASE WHEN sqlc.arg(ascending)::boolean THEN t.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN t.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: GetChildItem :one +SELECT i.*, t.created_at AS turn_created_at, + COALESCE(i.payload = sqlc.narg(candidate)::jsonb, false)::boolean AS payload_equal +FROM subagent_items i JOIN subagent_turns t ON t.id = i.turn_id +WHERE i.session_id = $1 AND i.subagent_id = $2 AND i.id = $3; + +-- name: PutChildItem :one +INSERT INTO subagent_items (id, session_id, subagent_id, turn_id, position, payload, output_index) +VALUES (sqlc.arg(id),sqlc.arg(session_id),sqlc.arg(subagent_id),sqlc.arg(turn_id),sqlc.arg(position),sqlc.arg(payload), + CASE WHEN sqlc.arg(is_output)::boolean THEN + (SELECT COALESCE(max(output_index),-1)+1 FROM subagent_items WHERE turn_id=sqlc.arg(turn_id)) END) +ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload +RETURNING output_index; + +-- name: ListChildItems :many +SELECT i.payload FROM subagent_items i JOIN subagent_turns t ON t.id = i.turn_id +WHERE i.session_id = sqlc.arg(session_id) AND i.subagent_id = sqlc.arg(subagent_id) + AND (sqlc.narg(turn_id)::uuid IS NULL OR i.turn_id = sqlc.narg(turn_id)) + AND (sqlc.narg(after_created)::timestamptz IS NULL + OR (sqlc.arg(ascending)::boolean AND (t.created_at, t.id, i.position, i.id) > (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_turn)::uuid, sqlc.arg(after_position)::integer, sqlc.arg(after_id)::uuid)) + OR (NOT sqlc.arg(ascending)::boolean AND (t.created_at, t.id, i.position, i.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_turn)::uuid, sqlc.arg(after_position)::integer, sqlc.arg(after_id)::uuid))) +ORDER BY CASE WHEN sqlc.arg(ascending)::boolean THEN t.created_at END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN t.id END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.position END ASC, + CASE WHEN sqlc.arg(ascending)::boolean THEN i.id END ASC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.created_at END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN t.id END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.position END DESC, + CASE WHEN NOT sqlc.arg(ascending)::boolean THEN i.id END DESC +LIMIT sqlc.arg(page_limit); + +-- name: SubagentRootAgent :one +SELECT (configuration->'agent'->>'id')::text AS agent_id FROM sessions WHERE id = $1; diff --git a/services/agents-api/internal/db/queries/turn_reads.sql b/services/agents-api/internal/db/queries/turn_reads.sql index 65b921041..2b76c9f68 100644 --- a/services/agents-api/internal/db/queries/turn_reads.sql +++ b/services/agents-api/internal/db/queries/turn_reads.sql @@ -1,5 +1,5 @@ -- name: ListTurns :many -SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id +SELECT t.* FROM public_execution_turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = sqlc.arg(tenant_id) AND t.session_id = sqlc.arg(session_id) AND (sqlc.narg(after_created)::timestamptz IS NULL OR (NOT sqlc.arg(ascending)::boolean AND (t.created_at, t.id) < (sqlc.narg(after_created)::timestamptz, sqlc.arg(after_id)::uuid)) diff --git a/services/agents-api/internal/db/queries/turns.sql b/services/agents-api/internal/db/queries/turns.sql index 2a0477a16..0caa8416c 100644 --- a/services/agents-api/internal/db/queries/turns.sql +++ b/services/agents-api/internal/db/queries/turns.sql @@ -37,7 +37,7 @@ UPDATE turns SET status = sqlc.arg(new_status), outcome = sqlc.arg(outcome), started_at = CASE WHEN sqlc.arg(new_status)::text = 'in_progress' THEN COALESCE(started_at, clock_timestamp()) ELSE started_at END, completed_at = CASE WHEN sqlc.arg(new_status)::text IN ('completed', 'failed', 'cancelled') - THEN clock_timestamp() ELSE NULL END + THEN COALESCE(sqlc.narg(source_completed_at)::timestamptz, clock_timestamp()) ELSE NULL END WHERE id = sqlc.arg(id) AND session_id = sqlc.arg(session_id) AND status = sqlc.arg(expected_status) AND status IN ('queued', 'in_progress', 'waiting') diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 7b2405992..ceb478f18 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -128,6 +128,20 @@ type InitialEnvironmentFile struct { Contents []byte `json:"contents"` } +type PublicExecutionTurn struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CancelRequestedAt pgtype.Timestamptz `json:"cancel_requested_at"` + Outcome []byte `json:"outcome"` + TokenUsage []byte `json:"token_usage"` + ArtifactCaptureStarted bool `json:"artifact_capture_started"` + SubagentID pgtype.UUID `json:"subagent_id"` +} + type RuntimeAllocation struct { ID pgtype.UUID `json:"id"` EnvironmentID pgtype.UUID `json:"environment_id"` @@ -238,6 +252,12 @@ type SourceFile struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type SubagentEffect struct { + SessionID pgtype.UUID `json:"session_id"` + EffectID string `json:"effect_id"` + Payload []byte `json:"payload"` +} + type SubagentIdentity struct { ID pgtype.UUID `json:"id"` SessionID pgtype.UUID `json:"session_id"` @@ -248,6 +268,34 @@ type SubagentIdentity struct { NativeCreatedAt int64 `json:"native_created_at"` FirstTurnID pgtype.UUID `json:"first_turn_id"` FirstEventOrdinal int32 `json:"first_event_ordinal"` + Name pgtype.Text `json:"name"` + Instructions pgtype.Text `json:"instructions"` + PublicVisible bool `json:"public_visible"` + Status string `json:"status"` + ClosedAtMs pgtype.Int8 `json:"closed_at_ms"` + LifecycleAtMs int64 `json:"lifecycle_at_ms"` +} + +type SubagentItem struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + TurnID pgtype.UUID `json:"turn_id"` + Position int32 `json:"position"` + OutputIndex pgtype.Int4 `json:"output_index"` + Payload []byte `json:"payload"` +} + +type SubagentTurn struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + NativeID string `json:"native_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + TokenUsage []byte `json:"token_usage"` } type Turn struct { diff --git a/services/agents-api/internal/db/sqlc/subagent_identities.sql.go b/services/agents-api/internal/db/sqlc/subagent_identities.sql.go index 0b9e7d14b..112e42424 100644 --- a/services/agents-api/internal/db/sqlc/subagent_identities.sql.go +++ b/services/agents-api/internal/db/sqlc/subagent_identities.sql.go @@ -12,7 +12,7 @@ import ( ) const getSubagentIdentity = `-- name: GetSubagentIdentity :one -SELECT i.id, i.session_id, i.device_id, i.engine, i.native_id, i.parent_native_id, i.native_created_at, i.first_turn_id, i.first_event_ordinal, e.created_at AS first_observed_at FROM subagent_identities i +SELECT i.id, i.session_id, i.device_id, i.engine, i.native_id, i.parent_native_id, i.native_created_at, i.first_turn_id, i.first_event_ordinal, i.name, i.instructions, i.public_visible, i.status, i.closed_at_ms, i.lifecycle_at_ms, e.created_at AS first_observed_at FROM subagent_identities i JOIN sessions s ON s.id = i.session_id JOIN turn_events e ON e.turn_id = i.first_turn_id AND e.ordinal = i.first_event_ordinal WHERE s.tenant_id = $1 AND s.id = $2 @@ -35,6 +35,12 @@ type GetSubagentIdentityRow struct { NativeCreatedAt int64 `json:"native_created_at"` FirstTurnID pgtype.UUID `json:"first_turn_id"` FirstEventOrdinal int32 `json:"first_event_ordinal"` + Name pgtype.Text `json:"name"` + Instructions pgtype.Text `json:"instructions"` + PublicVisible bool `json:"public_visible"` + Status string `json:"status"` + ClosedAtMs pgtype.Int8 `json:"closed_at_ms"` + LifecycleAtMs int64 `json:"lifecycle_at_ms"` FirstObservedAt pgtype.Timestamptz `json:"first_observed_at"` } @@ -51,6 +57,12 @@ func (q *Queries) GetSubagentIdentity(ctx context.Context, arg GetSubagentIdenti &i.NativeCreatedAt, &i.FirstTurnID, &i.FirstEventOrdinal, + &i.Name, + &i.Instructions, + &i.PublicVisible, + &i.Status, + &i.ClosedAtMs, + &i.LifecycleAtMs, &i.FirstObservedAt, ) return i, err @@ -65,11 +77,14 @@ SELECT $1, s.id, b.device_id, s.engine, $2, $3, $4, $5, $6 FROM sessions s JOIN session_devices b ON b.session_id = s.id WHERE s.id = $7 - AND (b.native_session_id = '' OR b.native_session_id = $3) - AND NOT EXISTS ( - SELECT 1 FROM subagent_identities old - WHERE old.session_id = s.id AND old.parent_native_id <> $3 - ) + AND (b.native_session_id = $3 + OR EXISTS (SELECT 1 FROM subagent_identities p + WHERE p.session_id = s.id AND p.native_id = $3) + OR (b.native_session_id = '' AND NOT EXISTS + (SELECT 1 FROM subagent_identities old WHERE old.session_id = s.id + AND old.parent_native_id <> $3 + AND NOT EXISTS (SELECT 1 FROM subagent_identities ancestor + WHERE ancestor.session_id = s.id AND ancestor.native_id = old.parent_native_id)))) ON CONFLICT (device_id, engine, native_id) DO UPDATE SET id = subagent_identities.id WHERE subagent_identities.session_id = EXCLUDED.session_id AND subagent_identities.parent_native_id = EXCLUDED.parent_native_id diff --git a/services/agents-api/internal/db/sqlc/subagent_resources.sql.go b/services/agents-api/internal/db/sqlc/subagent_resources.sql.go new file mode 100644 index 000000000..6c7f91337 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/subagent_resources.sql.go @@ -0,0 +1,498 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: subagent_resources.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const applySubagentLifecycle = `-- name: ApplySubagentLifecycle :exec +UPDATE subagent_identities SET status = $3, closed_at_ms = $4, lifecycle_at_ms = $5 +WHERE session_id = $1 AND id = $2 +` + +type ApplySubagentLifecycleParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + ClosedAtMs pgtype.Int8 `json:"closed_at_ms"` + LifecycleAtMs int64 `json:"lifecycle_at_ms"` +} + +func (q *Queries) ApplySubagentLifecycle(ctx context.Context, arg ApplySubagentLifecycleParams) error { + _, err := q.db.Exec(ctx, applySubagentLifecycle, + arg.SessionID, + arg.ID, + arg.Status, + arg.ClosedAtMs, + arg.LifecycleAtMs, + ) + return err +} + +const getChildItem = `-- name: GetChildItem :one +SELECT i.id, i.session_id, i.subagent_id, i.turn_id, i.position, i.output_index, i.payload, t.created_at AS turn_created_at, + COALESCE(i.payload = $4::jsonb, false)::boolean AS payload_equal +FROM subagent_items i JOIN subagent_turns t ON t.id = i.turn_id +WHERE i.session_id = $1 AND i.subagent_id = $2 AND i.id = $3 +` + +type GetChildItemParams struct { + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + ID pgtype.UUID `json:"id"` + Candidate []byte `json:"candidate"` +} + +type GetChildItemRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + TurnID pgtype.UUID `json:"turn_id"` + Position int32 `json:"position"` + OutputIndex pgtype.Int4 `json:"output_index"` + Payload []byte `json:"payload"` + TurnCreatedAt pgtype.Timestamptz `json:"turn_created_at"` + PayloadEqual bool `json:"payload_equal"` +} + +func (q *Queries) GetChildItem(ctx context.Context, arg GetChildItemParams) (GetChildItemRow, error) { + row := q.db.QueryRow(ctx, getChildItem, + arg.SessionID, + arg.SubagentID, + arg.ID, + arg.Candidate, + ) + var i GetChildItemRow + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SubagentID, + &i.TurnID, + &i.Position, + &i.OutputIndex, + &i.Payload, + &i.TurnCreatedAt, + &i.PayloadEqual, + ) + return i, err +} + +const getChildTurn = `-- name: GetChildTurn :one +SELECT id, session_id, subagent_id, native_id, status, created_at, started_at, completed_at, token_usage FROM subagent_turns WHERE session_id = $1 AND id = $2 +` + +type GetChildTurnParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) GetChildTurn(ctx context.Context, arg GetChildTurnParams) (SubagentTurn, error) { + row := q.db.QueryRow(ctx, getChildTurn, arg.SessionID, arg.ID) + var i SubagentTurn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SubagentID, + &i.NativeID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.TokenUsage, + ) + return i, err +} + +const getNativeSubagent = `-- name: GetNativeSubagent :one +SELECT id, session_id, device_id, engine, native_id, parent_native_id, native_created_at, first_turn_id, first_event_ordinal, name, instructions, public_visible, status, closed_at_ms, lifecycle_at_ms FROM subagent_identities WHERE session_id = $1 AND native_id = $2 +` + +type GetNativeSubagentParams struct { + SessionID pgtype.UUID `json:"session_id"` + NativeID string `json:"native_id"` +} + +func (q *Queries) GetNativeSubagent(ctx context.Context, arg GetNativeSubagentParams) (SubagentIdentity, error) { + row := q.db.QueryRow(ctx, getNativeSubagent, arg.SessionID, arg.NativeID) + var i SubagentIdentity + err := row.Scan( + &i.ID, + &i.SessionID, + &i.DeviceID, + &i.Engine, + &i.NativeID, + &i.ParentNativeID, + &i.NativeCreatedAt, + &i.FirstTurnID, + &i.FirstEventOrdinal, + &i.Name, + &i.Instructions, + &i.PublicVisible, + &i.Status, + &i.ClosedAtMs, + &i.LifecycleAtMs, + ) + return i, err +} + +const getPublicSubagent = `-- name: GetPublicSubagent :one +SELECT i.id, i.session_id, i.device_id, i.engine, i.native_id, i.parent_native_id, i.native_created_at, i.first_turn_id, i.first_event_ordinal, i.name, i.instructions, i.public_visible, i.status, i.closed_at_ms, i.lifecycle_at_ms, COALESCE(p.id::text, s.configuration->'agent'->>'id')::text AS parent_agent_id +FROM subagent_identities i JOIN sessions s ON s.id = i.session_id +LEFT JOIN subagent_identities p ON p.session_id = i.session_id AND p.native_id = i.parent_native_id +WHERE i.session_id = $1 AND i.id = $2 AND i.public_visible +` + +type GetPublicSubagentParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` +} + +type GetPublicSubagentRow struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + DeviceID pgtype.UUID `json:"device_id"` + Engine string `json:"engine"` + NativeID string `json:"native_id"` + ParentNativeID string `json:"parent_native_id"` + NativeCreatedAt int64 `json:"native_created_at"` + FirstTurnID pgtype.UUID `json:"first_turn_id"` + FirstEventOrdinal int32 `json:"first_event_ordinal"` + Name pgtype.Text `json:"name"` + Instructions pgtype.Text `json:"instructions"` + PublicVisible bool `json:"public_visible"` + Status string `json:"status"` + ClosedAtMs pgtype.Int8 `json:"closed_at_ms"` + LifecycleAtMs int64 `json:"lifecycle_at_ms"` + ParentAgentID string `json:"parent_agent_id"` +} + +func (q *Queries) GetPublicSubagent(ctx context.Context, arg GetPublicSubagentParams) (GetPublicSubagentRow, error) { + row := q.db.QueryRow(ctx, getPublicSubagent, arg.SessionID, arg.ID) + var i GetPublicSubagentRow + err := row.Scan( + &i.ID, + &i.SessionID, + &i.DeviceID, + &i.Engine, + &i.NativeID, + &i.ParentNativeID, + &i.NativeCreatedAt, + &i.FirstTurnID, + &i.FirstEventOrdinal, + &i.Name, + &i.Instructions, + &i.PublicVisible, + &i.Status, + &i.ClosedAtMs, + &i.LifecycleAtMs, + &i.ParentAgentID, + ) + return i, err +} + +const listChildItems = `-- name: ListChildItems :many +SELECT i.payload FROM subagent_items i JOIN subagent_turns t ON t.id = i.turn_id +WHERE i.session_id = $1 AND i.subagent_id = $2 + AND ($3::uuid IS NULL OR i.turn_id = $3) + AND ($4::timestamptz IS NULL + OR ($5::boolean AND (t.created_at, t.id, i.position, i.id) > ($4::timestamptz, $6::uuid, $7::integer, $8::uuid)) + OR (NOT $5::boolean AND (t.created_at, t.id, i.position, i.id) < ($4::timestamptz, $6::uuid, $7::integer, $8::uuid))) +ORDER BY CASE WHEN $5::boolean THEN t.created_at END ASC, + CASE WHEN $5::boolean THEN t.id END ASC, + CASE WHEN $5::boolean THEN i.position END ASC, + CASE WHEN $5::boolean THEN i.id END ASC, + CASE WHEN NOT $5::boolean THEN t.created_at END DESC, + CASE WHEN NOT $5::boolean THEN t.id END DESC, + CASE WHEN NOT $5::boolean THEN i.position END DESC, + CASE WHEN NOT $5::boolean THEN i.id END DESC +LIMIT $9 +` + +type ListChildItemsParams struct { + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + TurnID pgtype.UUID `json:"turn_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterTurn pgtype.UUID `json:"after_turn"` + AfterPosition int32 `json:"after_position"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListChildItems(ctx context.Context, arg ListChildItemsParams) ([][]byte, error) { + rows, err := q.db.Query(ctx, listChildItems, + arg.SessionID, + arg.SubagentID, + arg.TurnID, + arg.AfterCreated, + arg.Ascending, + arg.AfterTurn, + arg.AfterPosition, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := [][]byte{} + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, err + } + items = append(items, payload) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChildTurns = `-- name: ListChildTurns :many +SELECT id, session_id, subagent_id, native_id, status, created_at, started_at, completed_at, token_usage FROM subagent_turns t +WHERE t.session_id = $1 AND t.subagent_id = $2 + AND ($3::timestamptz IS NULL + OR ($4::boolean AND (t.created_at, t.id) > ($3::timestamptz, $5::uuid)) + OR (NOT $4::boolean AND (t.created_at, t.id) < ($3::timestamptz, $5::uuid))) +ORDER BY CASE WHEN $4::boolean THEN t.created_at END ASC, + CASE WHEN $4::boolean THEN t.id END ASC, + CASE WHEN NOT $4::boolean THEN t.created_at END DESC, + CASE WHEN NOT $4::boolean THEN t.id END DESC +LIMIT $6 +` + +type ListChildTurnsParams struct { + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + AfterCreated pgtype.Timestamptz `json:"after_created"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListChildTurns(ctx context.Context, arg ListChildTurnsParams) ([]SubagentTurn, error) { + rows, err := q.db.Query(ctx, listChildTurns, + arg.SessionID, + arg.SubagentID, + arg.AfterCreated, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SubagentTurn{} + for rows.Next() { + var i SubagentTurn + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SubagentID, + &i.NativeID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.TokenUsage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPublicSubagents = `-- name: ListPublicSubagents :many +SELECT i.id FROM subagent_identities i +WHERE i.session_id = $1 AND i.public_visible + AND ($2::bigint IS NULL + OR ($3::boolean AND (i.native_created_at, i.id) > ($2::bigint, $4::uuid)) + OR (NOT $3::boolean AND (i.native_created_at, i.id) < ($2::bigint, $4::uuid))) +ORDER BY CASE WHEN $3::boolean THEN i.native_created_at END ASC, + CASE WHEN $3::boolean THEN i.id END ASC, + CASE WHEN NOT $3::boolean THEN i.native_created_at END DESC, + CASE WHEN NOT $3::boolean THEN i.id END DESC +LIMIT $5 +` + +type ListPublicSubagentsParams struct { + SessionID pgtype.UUID `json:"session_id"` + AfterOpened pgtype.Int8 `json:"after_opened"` + Ascending bool `json:"ascending"` + AfterID pgtype.UUID `json:"after_id"` + PageLimit int32 `json:"page_limit"` +} + +func (q *Queries) ListPublicSubagents(ctx context.Context, arg ListPublicSubagentsParams) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listPublicSubagents, + arg.SessionID, + arg.AfterOpened, + arg.Ascending, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []pgtype.UUID{} + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const publishSubagent = `-- name: PublishSubagent :exec +UPDATE subagent_identities SET public_visible = true, name = $3, instructions = $4 WHERE session_id = $1 AND id = $2 +` + +type PublishSubagentParams struct { + SessionID pgtype.UUID `json:"session_id"` + ID pgtype.UUID `json:"id"` + Name pgtype.Text `json:"name"` + Instructions pgtype.Text `json:"instructions"` +} + +func (q *Queries) PublishSubagent(ctx context.Context, arg PublishSubagentParams) error { + _, err := q.db.Exec(ctx, publishSubagent, + arg.SessionID, + arg.ID, + arg.Name, + arg.Instructions, + ) + return err +} + +const putChildItem = `-- name: PutChildItem :one +INSERT INTO subagent_items (id, session_id, subagent_id, turn_id, position, payload, output_index) +VALUES ($1,$2,$3,$4,$5,$6, + CASE WHEN $7::boolean THEN + (SELECT COALESCE(max(output_index),-1)+1 FROM subagent_items WHERE turn_id=$4) END) +ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload +RETURNING output_index +` + +type PutChildItemParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + TurnID pgtype.UUID `json:"turn_id"` + Position int32 `json:"position"` + Payload []byte `json:"payload"` + IsOutput bool `json:"is_output"` +} + +func (q *Queries) PutChildItem(ctx context.Context, arg PutChildItemParams) (pgtype.Int4, error) { + row := q.db.QueryRow(ctx, putChildItem, + arg.ID, + arg.SessionID, + arg.SubagentID, + arg.TurnID, + arg.Position, + arg.Payload, + arg.IsOutput, + ) + var output_index pgtype.Int4 + err := row.Scan(&output_index) + return output_index, err +} + +const putChildTurn = `-- name: PutChildTurn :one +INSERT INTO subagent_turns (id, session_id, subagent_id, native_id, status, created_at, started_at, completed_at, token_usage) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, started_at = EXCLUDED.started_at, + completed_at = EXCLUDED.completed_at, token_usage = EXCLUDED.token_usage +WHERE subagent_turns.session_id = EXCLUDED.session_id AND subagent_turns.subagent_id = EXCLUDED.subagent_id + AND subagent_turns.native_id = EXCLUDED.native_id AND subagent_turns.created_at = EXCLUDED.created_at +RETURNING id, session_id, subagent_id, native_id, status, created_at, started_at, completed_at, token_usage +` + +type PutChildTurnParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + SubagentID pgtype.UUID `json:"subagent_id"` + NativeID string `json:"native_id"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + TokenUsage []byte `json:"token_usage"` +} + +func (q *Queries) PutChildTurn(ctx context.Context, arg PutChildTurnParams) (SubagentTurn, error) { + row := q.db.QueryRow(ctx, putChildTurn, + arg.ID, + arg.SessionID, + arg.SubagentID, + arg.NativeID, + arg.Status, + arg.CreatedAt, + arg.StartedAt, + arg.CompletedAt, + arg.TokenUsage, + ) + var i SubagentTurn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SubagentID, + &i.NativeID, + &i.Status, + &i.CreatedAt, + &i.StartedAt, + &i.CompletedAt, + &i.TokenUsage, + ) + return i, err +} + +const putSubagentEffect = `-- name: PutSubagentEffect :one +INSERT INTO subagent_effects (session_id, effect_id, payload) VALUES ($1, $2, $3) +ON CONFLICT (session_id, effect_id) DO UPDATE SET effect_id = subagent_effects.effect_id +WHERE subagent_effects.payload = EXCLUDED.payload +RETURNING (xmax = 0)::boolean AS inserted +` + +type PutSubagentEffectParams struct { + SessionID pgtype.UUID `json:"session_id"` + EffectID string `json:"effect_id"` + Payload []byte `json:"payload"` +} + +func (q *Queries) PutSubagentEffect(ctx context.Context, arg PutSubagentEffectParams) (bool, error) { + row := q.db.QueryRow(ctx, putSubagentEffect, arg.SessionID, arg.EffectID, arg.Payload) + var inserted bool + err := row.Scan(&inserted) + return inserted, err +} + +const subagentRootAgent = `-- name: SubagentRootAgent :one +SELECT (configuration->'agent'->>'id')::text AS agent_id FROM sessions WHERE id = $1 +` + +func (q *Queries) SubagentRootAgent(ctx context.Context, id pgtype.UUID) (string, error) { + row := q.db.QueryRow(ctx, subagentRootAgent, id) + var agent_id string + err := row.Scan(&agent_id) + return agent_id, err +} diff --git a/services/agents-api/internal/db/sqlc/turn_reads.sql.go b/services/agents-api/internal/db/sqlc/turn_reads.sql.go index b2137b465..5cab1dba9 100644 --- a/services/agents-api/internal/db/sqlc/turn_reads.sql.go +++ b/services/agents-api/internal/db/sqlc/turn_reads.sql.go @@ -12,7 +12,7 @@ import ( ) const listTurns = `-- name: ListTurns :many -SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.event_count, t.event_bytes, t.token_usage, t.artifact_capture_started FROM turns t JOIN sessions s ON s.id = t.session_id +SELECT t.id, t.session_id, t.status, t.created_at, t.started_at, t.completed_at, t.cancel_requested_at, t.outcome, t.token_usage, t.artifact_capture_started, t.subagent_id FROM public_execution_turns t JOIN sessions s ON s.id = t.session_id WHERE s.tenant_id = $1 AND t.session_id = $2 AND ($3::timestamptz IS NULL OR (NOT $4::boolean AND (t.created_at, t.id) < ($3::timestamptz, $5::uuid)) @@ -34,7 +34,7 @@ type ListTurnsParams struct { PageLimit int32 `json:"page_limit"` } -func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]Turn, error) { +func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]PublicExecutionTurn, error) { rows, err := q.db.Query(ctx, listTurns, arg.TenantID, arg.SessionID, @@ -47,9 +47,9 @@ func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]Turn, e return nil, err } defer rows.Close() - items := []Turn{} + items := []PublicExecutionTurn{} for rows.Next() { - var i Turn + var i PublicExecutionTurn if err := rows.Scan( &i.ID, &i.SessionID, @@ -59,10 +59,9 @@ func (q *Queries) ListTurns(ctx context.Context, arg ListTurnsParams) ([]Turn, e &i.CompletedAt, &i.CancelRequestedAt, &i.Outcome, - &i.EventCount, - &i.EventBytes, &i.TokenUsage, &i.ArtifactCaptureStarted, + &i.SubagentID, ); err != nil { return nil, err } diff --git a/services/agents-api/internal/db/sqlc/turns.sql.go b/services/agents-api/internal/db/sqlc/turns.sql.go index 1c718729f..b3093ccbc 100644 --- a/services/agents-api/internal/db/sqlc/turns.sql.go +++ b/services/agents-api/internal/db/sqlc/turns.sql.go @@ -277,26 +277,28 @@ UPDATE turns SET status = $1, outcome = $2, started_at = CASE WHEN $1::text = 'in_progress' THEN COALESCE(started_at, clock_timestamp()) ELSE started_at END, completed_at = CASE WHEN $1::text IN ('completed', 'failed', 'cancelled') - THEN clock_timestamp() ELSE NULL END -WHERE id = $3 AND session_id = $4 - AND status = $5 + THEN COALESCE($3::timestamptz, clock_timestamp()) ELSE NULL END +WHERE id = $4 AND session_id = $5 + AND status = $6 AND status IN ('queued', 'in_progress', 'waiting') AND ($1::text <> 'in_progress' OR cancel_requested_at IS NULL) RETURNING id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, event_count, event_bytes, token_usage, artifact_capture_started ` type TransitionTurnParams struct { - NewStatus string `json:"new_status"` - Outcome []byte `json:"outcome"` - ID pgtype.UUID `json:"id"` - SessionID pgtype.UUID `json:"session_id"` - ExpectedStatus string `json:"expected_status"` + NewStatus string `json:"new_status"` + Outcome []byte `json:"outcome"` + SourceCompletedAt pgtype.Timestamptz `json:"source_completed_at"` + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + ExpectedStatus string `json:"expected_status"` } func (q *Queries) TransitionTurn(ctx context.Context, arg TransitionTurnParams) (Turn, error) { row := q.db.QueryRow(ctx, transitionTurn, arg.NewStatus, arg.Outcome, + arg.SourceCompletedAt, arg.ID, arg.SessionID, arg.ExpectedStatus, diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go index 135054c24..4698cc113 100644 --- a/services/agents-api/internal/engine/claude.go +++ b/services/agents-api/internal/engine/claude.go @@ -32,10 +32,10 @@ func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, ha if agent.Text.Verbosity != "" && agent.Text.Verbosity != "medium" { return errors.New("The configured engine currently supports medium text verbosity only.") } - if agent.MultiAgent.Enabled || agent.MultiAgent.MaxConcurrentSubagents != nil || agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { + if agent.Reasoning.Effort != nil || agent.Reasoning.Summary != nil || (agent.ServiceTier != "" && agent.ServiceTier != "auto") || (agent.Text.Format.Type != "" && agent.Text.Format.Type != "text") { return ErrInvalidInput } - return nil + return rejectSubagentTools(agent, "function", "mcp") } func validateClaudeTools(environment *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { diff --git a/services/agents-api/internal/engine/codex.go b/services/agents-api/internal/engine/codex.go index 14a75ab9a..24728ec2e 100644 --- a/services/agents-api/internal/engine/codex.go +++ b/services/agents-api/internal/engine/codex.go @@ -11,6 +11,9 @@ func codexProfile() Profile { return Profile{ Placements: []string{"none", "self_hosted", "openai_hosted"}, WebSearchControl: true, TextVerbosity: true, MCPBearer: true, + ValidateConfiguration: func(agent v1.Agent, _ *v1.Environment, _ bool) error { + return rejectSubagentTools(agent, "function", "mcp") + }, ValidateTools: func(environment *v1.Environment, hasDaemon bool, _ []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { if len(mcp) != 0 && (environment == nil || environment.Type != "none" || hasDaemon) { return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go index 47574a16c..d664494c6 100644 --- a/services/agents-api/internal/engine/mcode.go +++ b/services/agents-api/internal/engine/mcode.go @@ -10,7 +10,7 @@ import ( func mcodeProfile() Profile { return Profile{Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { - if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.MultiAgent.Enabled || a.MultiAgent.MaxConcurrentSubagents != nil || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { + if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { return ErrInvalidInput } return nil diff --git a/services/agents-api/internal/engine/subagents.go b/services/agents-api/internal/engine/subagents.go new file mode 100644 index 000000000..b92ae40d9 --- /dev/null +++ b/services/agents-api/internal/engine/subagents.go @@ -0,0 +1,28 @@ +package engine + +import ( + "encoding/json" + "errors" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +// rejectSubagentTools keeps unqualified native combinations in adapter profiles. +func rejectSubagentTools(agent v1.Agent, unsupported ...string) error { + if !agent.MultiAgent.Enabled { + return nil + } + for _, raw := range agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return ErrInvalidInput + } + if slices.Contains(unsupported, tool.Type) { + return errors.New("The configured Subagent profile does not support this tool combination.") + } + } + return nil +} diff --git a/services/agents-api/internal/engine/subagents_test.go b/services/agents-api/internal/engine/subagents_test.go new file mode 100644 index 000000000..7cf0c7ab1 --- /dev/null +++ b/services/agents-api/internal/engine/subagents_test.go @@ -0,0 +1,42 @@ +package engine + +import ( + "encoding/json" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" +) + +func TestSubagentProfilesPreserveQualifiedOperationBoundaries(t *testing.T) { + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + t.Run(kind, func(t *testing.T) { + profile, ok := (Catalog{}).Lookup(kind) + if !ok { + t.Fatal("profile missing") + } + agent := v1.Agent{Model: "real-model"} + agent.MultiAgent.Enabled = true + for _, placement := range []string{"none", "openai_hosted", "self_hosted"} { + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: placement}, false); err != nil { + t.Fatal(placement, err) + } + } + // Public functions and MCP remain independently qualified capabilities; new + // child observation support does not automatically admit their combinations. + if kind == "mcode" { + return + } // Its existing tool profile rejects both for every Session. + for _, tool := range []string{`{"type":"function","name":"f"}`, `{"type":"mcp","server_label":"s"}`} { + agent.Tools = []json.RawMessage{json.RawMessage(tool)} + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err == nil { + t.Fatal("unqualified multi-agent combination accepted", tool) + } + agent.MultiAgent.Enabled = false + if err := profile.ValidateConfiguration(agent, &v1.Environment{Type: "none"}, false); err != nil { + t.Fatal("single-agent profile changed", err) + } + agent.MultiAgent.Enabled = true + } + }) + } +} diff --git a/services/agents-api/internal/execution/delivery.go b/services/agents-api/internal/execution/delivery.go index d6ff50b73..9dde76a62 100644 --- a/services/agents-api/internal/execution/delivery.go +++ b/services/agents-api/internal/execution/delivery.go @@ -321,6 +321,9 @@ func (r *Result) mergeDone(raw json.RawMessage) error { if done.Metadata == nil { done.Metadata = r.Done.Metadata } + if done.SourceCompletedAtMS == nil { + done.SourceCompletedAtMS = r.Done.SourceCompletedAtMS + } r.Done = done return nil } diff --git a/services/agents-api/internal/execution/journal.go b/services/agents-api/internal/execution/journal.go index c68de7703..ae22830de 100644 --- a/services/agents-api/internal/execution/journal.go +++ b/services/agents-api/internal/execution/journal.go @@ -54,12 +54,12 @@ func (j *journal) observe(ctx context.Context, env proto.Envelope) error { } func (j *journal) enqueue(env proto.Envelope) error { - if env.Type == proto.TypeSubagentIdentity && !j.observeSubagents { + if (env.Type == proto.TypeSubagentIdentity || env.Type == proto.TypeSubagentLifecycle || env.Type == proto.TypeSubagentTurn || env.Type == proto.TypeSubagentItem || env.Type == proto.TypeSubagentCoordination) && !j.observeSubagents { return store.ErrInvalidInput } switch env.Type { case proto.TypeDelta, proto.TypeOutputMessage, proto.TypeThinking, proto.TypeToolCall, proto.TypeCommandOutput, proto.TypeUsage, - proto.TypeError, proto.TypeDone, proto.TypePromptSteerAck, proto.TypeSubagentIdentity, "cancel_receipt": + proto.TypeError, proto.TypeDone, proto.TypePromptSteerAck, proto.TypeSubagentIdentity, proto.TypeSubagentLifecycle, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination, "cancel_receipt": default: return nil } diff --git a/services/agents-api/internal/execution/mcode_profile_test.go b/services/agents-api/internal/execution/mcode_profile_test.go index 8cbf8e5d0..f24dbd8a5 100644 --- a/services/agents-api/internal/execution/mcode_profile_test.go +++ b/services/agents-api/internal/execution/mcode_profile_test.go @@ -11,7 +11,7 @@ func TestMCodeOperationQualification(t *testing.T) { {"text", `{"agent":{"model":"real-model"},"environment":{"type":"none"}}`, true}, {"hosted", `{"agent":{"model":"real-model"},"environment":{"type":"openai_hosted"}}`, true}, {"verbosity", `{"agent":{"model":"real-model","text":{"verbosity":"high"}},"environment":{"type":"none"}}`, false}, - {"subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true}},"environment":{"type":"none"}}`, false}, + {"subagents", `{"agent":{"model":"real-model","multi_agent":{"enabled":true}},"environment":{"type":"none"}}`, true}, {"functions", `{"agent":{"model":"real-model","tools":[{"type":"function","name":"f","parameters":{"type":"object"}}]},"environment":{"type":"none"}}`, false}, {"mcp", `{"agent":{"model":"real-model","tools":[{"type":"mcp","server_label":"x","server_url":"https://example.invalid/mcp"}]},"environment":{"type":"none"}}`, false}, } { diff --git a/services/agents-api/internal/execution/request.go b/services/agents-api/internal/execution/request.go index a60182a71..89e06e231 100644 --- a/services/agents-api/internal/execution/request.go +++ b/services/agents-api/internal/execution/request.go @@ -48,6 +48,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session RequireExistingNativeSession: recoverNativeSession, ObserveMessages: caps.MessageItems, ObserveToolObservations: true, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, + MaxConcurrentSubagents: snapshot.Agent.MultiAgent.MaxConcurrentSubagents, DisableSubagents: !snapshot.Agent.MultiAgent.Enabled} if len(mcp) != 0 { selected, err := d.mcpExecutionCredentials(session.Engine, snapshot, mcp, caps) diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 02a20cc43..2c16aba54 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -91,6 +91,9 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !caps.ToolObservations { return fail("device must advertise tool_observations") } + if snapshot.Agent.MultiAgent.Enabled && !caps.SubagentObservations { + return fail("device must support durable subagent observations") + } if !snapshot.Agent.MultiAgent.Enabled && !caps.SubagentControl { return fail("device must advertise subagent_control") } diff --git a/services/agents-api/internal/items/messages.go b/services/agents-api/internal/items/messages.go index b64008677..9f1329137 100644 --- a/services/agents-api/internal/items/messages.go +++ b/services/agents-api/internal/items/messages.go @@ -51,7 +51,7 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, if err := json.Unmarshal(raw, &p); err != nil { return nil, err } - if p.ID == "" || (p.Status != "in_progress" && p.Status != "completed") { + if p.ID == "" || (p.Status != "in_progress" && p.Status != "completed" && p.Status != "incomplete") { return nil, errors.New("invalid message observation") } text := "" diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/agents-api/internal/store/dispatch_test.go index 699274d75..99480b023 100644 --- a/services/agents-api/internal/store/dispatch_test.go +++ b/services/agents-api/internal/store/dispatch_test.go @@ -92,7 +92,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool t.Fatal("device connection failed") } t.Cleanup(func() { h.conn.Close() }) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, NativeSessionRecovery: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, SubagentObservations: true, ToolObservations: true, NativeSessionRecovery: true}}}}) deadline := time.Now().Add(3 * time.Second) for { peer, e := h.registry.LookupDevice(h.device.ID) diff --git a/services/agents-api/internal/store/subagent_coordination.go b/services/agents-api/internal/store/subagent_coordination.go new file mode 100644 index 000000000..46b8db105 --- /dev/null +++ b/services/agents-api/internal/store/subagent_coordination.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func coordinationItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentCoordinationPayload) (v1.Item, error) { + value := v1.Item{ID: items.Identity(turn, "coordination:"+p.ID), TurnID: turn, Type: p.Kind, Status: p.Status, Model: p.Model, ReasoningEffort: p.ReasoningEffort} + if !validNativeIdentity(p.ID) { + return value, ErrInvalidInput + } + if p.Status != "in_progress" && p.Status != "completed" && p.Status != "failed" && p.Status != "incomplete" && p.Kind != "agent_message" { + return value, ErrInvalidInput + } + resolve := func(native string, failedReference bool) (string, error) { + if native == "" { + return q.SubagentRootAgent(ctx, session) + } + row, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: native}) + if errors.Is(err, pgx.ErrNoRows) && failedReference && validNativeIdentity(native) { + return native, nil + } + if err != nil { + return "", err + } + if !row.PublicVisible { + return "", ErrNotFound + } + return uuid.UUID(row.ID.Bytes).String(), nil + } + actor, err := resolve(p.ActorID, false) + if err != nil { + return value, err + } + if actor == "" { + return value, ErrInvalidInput + } + value.SenderAgentID = actor + if p.Text != nil { + value.Content = []v1.ItemContent{{Type: "output_text", Text: p.Text}} + } + switch p.Kind { + case "create_subagent_call": + value.AgentID = actor + case "wait_for_subagents_call": + value.RecipientAgentIDs = []string{} + for _, recipient := range p.Recipients { + id, err := resolve(recipient, p.Status == "failed") + if err != nil { + return value, err + } + value.RecipientAgentIDs = append(value.RecipientAgentIDs, id) + } + case "send_subagent_input_call", "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call", "agent_message": + if len(p.Recipients) != 1 { + return value, ErrInvalidInput + } + value.RecipientAgentID, err = resolve(p.Recipients[0], p.Status == "failed") + if err != nil { + return value, err + } + default: + return value, ErrInvalidInput + } + return value, nil +} +func projectRootCoordination(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, raw json.RawMessage, created pgtype.Timestamptz) error { + var p proto.SubagentCoordinationPayload + if json.Unmarshal(raw, &p) != nil || p.ActorID != "" { + return ErrInvalidInput + } + value, err := coordinationItem(ctx, q, session, uuid.UUID(turn.Bytes).String(), p) + if err != nil { + return err + } + id, _ := parseID(value.ID) + old, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + var previous v1.Item + if err == nil { + if err = json.Unmarshal(old.Payload, &previous); err != nil { + return err + } + } + merged, err := items.Merge(items.Update{Item: value}, previous) + if err != nil { + return err + } + payload, err := json.Marshal(merged) + if err != nil { + return err + } + row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: true}) + if err != nil { + return err + } + return recordItemChange(ctx, q, session, row.OutputIndex, previous, merged, nil) +} diff --git a/services/agents-api/internal/store/subagent_dispatch_test.go b/services/agents-api/internal/store/subagent_dispatch_test.go index 42ec46546..a032b0b3a 100644 --- a/services/agents-api/internal/store/subagent_dispatch_test.go +++ b/services/agents-api/internal/store/subagent_dispatch_test.go @@ -16,7 +16,7 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { h := newDispatchHarness(t) ctx := t.Context() configuration, _ := json.Marshal(map[string]any{ - "agent": map[string]any{"model": "test-model", "multi_agent": map[string]bool{"enabled": enabled}}, + "agent": map[string]any{"id": "agent_root", "model": "test-model", "multi_agent": map[string]any{"enabled": enabled, "max_concurrent_subagents": 3}}, "daemon": map[string]string{"work_dir": "/tmp"}, }) var err error @@ -36,7 +36,7 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { input := h.message("first", "root message") running := h.run(ctx, input.TurnID) var request proto.PromptRequestPayload - if err = h.read(proto.TypePromptRequest).DecodePayload(&request); err != nil || request.ObserveSubagentIdentities != enabled { + if err = h.read(proto.TypePromptRequest).DecodePayload(&request); err != nil || request.ObserveSubagentIdentities != enabled || request.MaxConcurrentSubagents == nil || *request.MaxConcurrentSubagents != 3 { t.Fatal("private observation policy not carried", err) } identity := proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 100, ParentTurnID: "native-turn", SourceItemID: "spawn-item"} @@ -49,6 +49,13 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { return } h.write(input.TurnID, proto.TypeSubagentIdentity, identity) + childTurn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: store.TurnInProgress, CreatedAtMS: 100000} + h.write(input.TurnID, proto.TypeSubagentTurn, childTurn) + h.write(input.TurnID, proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "child-turn", ItemID: "answer", Position: 0, Kind: proto.TypeOutputMessage, Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"child answer"}`)}) + completed := int64(101000) + childTurn.Status, childTurn.CompletedAtMS = store.TurnCompleted, &completed + h.write(input.TurnID, proto.TypeSubagentTurn, childTurn) + h.write(input.TurnID, proto.TypeSubagentLifecycle, proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "native-close", Status: "closed", OccurredAtMS: 102000}) h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "root result", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "root"}}) h.finished(running, store.TurnCompleted) saved, err := h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child") @@ -56,7 +63,7 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { t.Fatal(saved, err) } events, err := h.s.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) - if err != nil || len(events) != 4 || events[0].Kind != proto.TypeSubagentIdentity || events[1].Kind != proto.TypeSubagentIdentity || events[2].Kind != proto.TypeDone { + if err != nil || len(events) != 8 || events[0].Kind != proto.TypeSubagentIdentity || events[1].Kind != proto.TypeSubagentIdentity || events[2].Kind != proto.TypeSubagentTurn || events[3].Kind != proto.TypeSubagentItem || events[5].Kind != proto.TypeSubagentLifecycle || events[6].Kind != proto.TypeDone { t.Fatal("journal lost identity provenance or terminal ordering", events, err) } }) diff --git a/services/agents-api/internal/store/subagent_identities.go b/services/agents-api/internal/store/subagent_identities.go index 9d2b69b8c..b4bfd34ac 100644 --- a/services/agents-api/internal/store/subagent_identities.go +++ b/services/agents-api/internal/store/subagent_identities.go @@ -33,7 +33,7 @@ func projectSubagentIdentity(ctx context.Context, q *sqlc.Queries, session, turn return ErrInvalidInput } } - _, err := q.PutSubagentIdentity(ctx, sqlc.PutSubagentIdentityParams{ + id, err := q.PutSubagentIdentity(ctx, sqlc.PutSubagentIdentityParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session, NativeID: identity.NativeID, ParentNativeID: identity.ParentNativeID, NativeCreatedAt: identity.NativeCreatedAt, FirstTurnID: turn, FirstEventOrdinal: ordinal, @@ -41,7 +41,10 @@ func projectSubagentIdentity(ctx context.Context, q *sqlc.Queries, session, turn if errors.Is(err, pgx.ErrNoRows) { return ErrIdempotencyConflict } - return err + if err != nil { + return err + } + return publishSubagent(ctx, q, session, id, identity) } // GetSubagentIdentity recovers a binding in its authorized, visible Session. diff --git a/services/agents-api/internal/store/subagent_identities_test.go b/services/agents-api/internal/store/subagent_identities_test.go index 86549b763..b4b5c0232 100644 --- a/services/agents-api/internal/store/subagent_identities_test.go +++ b/services/agents-api/internal/store/subagent_identities_test.go @@ -23,7 +23,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { lease := executionLease(t, s) w := lease.Store() ctx := t.Context() - tenant, session := newTurnSession(t, s) + tenant, session := newSubagentSession(t, s) host, err := s.CreateDevice(ctx, tenant, "identity test", device.HashCredential(uuid.NewString())) if err != nil { t.Fatal(err) @@ -141,7 +141,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { func TestSubagentIdentityRejectsLostLease(t *testing.T) { s, pool := testStore(t) old := executionLease(t, s) - tenant, session := newTurnSession(t, s) + tenant, session := newSubagentSession(t, s) input := submitMessage(t, s, tenant, session.ID, "first") transition(t, old.Store(), tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) var killed bool diff --git a/services/agents-api/internal/store/subagent_item_projection.go b/services/agents-api/internal/store/subagent_item_projection.go new file mode 100644 index 000000000..237b6577d --- /dev/null +++ b/services/agents-api/internal/store/subagent_item_projection.go @@ -0,0 +1,145 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func isSubagentObservation(kind string) bool { + switch kind { + case proto.TypeSubagentIdentity, proto.TypeSubagentLifecycle, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: + return true + } + return false +} +func projectSubagentItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { + var p proto.SubagentItemPayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || !validNativeIdentity(p.ItemID) || p.Position < 0 || p.Position > 1073741823 { + return ErrInvalidInput + } + child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) + if err != nil { + return err + } + if !child.PublicVisible { + return ErrInvalidInput + } + turnID := items.Identity(uuid.UUID(child.ID.Bytes).String(), "turn:"+p.TurnID) + turn, err := childTurn(ctx, q, session, uuid.UUID(child.ID.Bytes).String(), turnID) + if err != nil { + return err + } + projected, err := childItems(ctx, q, session, turnID, p) + if err != nil { + return err + } + for offset, item := range projected { + if err := putChildItem(ctx, q, session, child.ID, turn, p.Position*2+int32(offset), item); err != nil { + return err + } + } + return nil +} + +func putChildItem(ctx context.Context, q *sqlc.Queries, session, childID pgtype.UUID, turn sqlc.SubagentTurn, position int32, item v1.Item) error { + payload, err := json.Marshal(item) + if err != nil { + return err + } + id, _ := parseID(item.ID) + old, err := q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: session, SubagentID: childID, ID: id, Candidate: payload}) + fresh := errors.Is(err, pgx.ErrNoRows) + if err != nil && !fresh { + return err + } + var previous v1.Item + if !fresh { + if old.TurnID != turn.ID || old.Position != position { + return ErrIdempotencyConflict + } + if err = json.Unmarshal(old.Payload, &previous); err != nil { + return err + } + if old.PayloadEqual { + return nil + } + if previous.Status != "in_progress" { + return ErrIdempotencyConflict + } + } + if terminalStatus(turn.Status) { + return ErrTurnConflict + } + index, err := q.PutChildItem(ctx, sqlc.PutChildItemParams{ID: id, SessionID: session, SubagentID: childID, TurnID: turn.ID, Position: position, Payload: payload, IsOutput: item.Role != "user" && item.Type != "function_call_output"}) + if err != nil { + return err + } + return recordItemChange(ctx, q, session, index, previous, item, nil) +} + +func childItems(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentItemPayload) ([]v1.Item, error) { + var result []v1.Item + switch p.Kind { + case proto.TypeSubagentCoordination: + var value proto.SubagentCoordinationPayload + if json.Unmarshal(p.Payload, &value) != nil || value.ID != p.ItemID || value.ActorID != p.NativeID { + return result, ErrInvalidInput + } + item, err := coordinationItem(ctx, q, session, turn, value) + return []v1.Item{item}, err + case proto.TypeOutputMessage: + var message proto.OutputMessagePayload + if json.Unmarshal(p.Payload, &message) != nil || message.ID != p.ItemID || message.Text == nil { + return result, ErrInvalidInput + } + case proto.TypeToolCall: + var call proto.ToolCallPayload + if json.Unmarshal(p.Payload, &call) != nil || call.ID != p.ItemID || len(call.NativeItem) > 0 || call.Observation == nil { + return result, ErrInvalidInput + } + case "message": + // Input projection validates and normalizes the existing public message shape. + case "reasoning": + var value struct { + Status string `json:"status"` + Summary []v1.SummaryText `json:"summary"` + } + if json.Unmarshal(p.Payload, &value) != nil { + return result, ErrInvalidInput + } + if value.Status != "" && value.Status != "in_progress" && value.Status != "completed" && value.Status != "incomplete" { + return result, ErrInvalidInput + } + for _, part := range value.Summary { + if part.Type != "summary_text" { + return result, ErrInvalidInput + } + } + return []v1.Item{{ID: items.Identity(turn, "reasoning:"+p.ItemID), TurnID: turn, Type: "reasoning", Status: value.Status, Summary: value.Summary}}, nil + default: + return result, ErrInvalidInput + } + updates, err := items.Project(turn, p.Kind, int64(p.Position), p.Payload) + if err != nil { + return result, err + } + if len(updates) < 1 || len(updates) > 2 { + return nil, ErrInvalidInput + } + for _, update := range updates { + if update.AppendText { + return nil, ErrInvalidInput + } + result = append(result, update.Item) + } + return result, nil +} diff --git a/services/agents-api/internal/store/subagent_item_reads.go b/services/agents-api/internal/store/subagent_item_reads.go new file mode 100644 index 000000000..a113139b8 --- /dev/null +++ b/services/agents-api/internal/store/subagent_item_reads.go @@ -0,0 +1,78 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) ListSubagentItems(ctx context.Context, tenant, session, child, after string, limit int, asc bool) (v1.ItemList, error) { + return s.listChildItems(ctx, tenant, session, child, "", after, limit, asc) +} +func (s *Store) ListSubagentTurnItems(ctx context.Context, tenant, session, child, turn, after string, limit int, asc bool) (v1.ItemList, error) { + if turn == "" { + return v1.ItemList{}, ErrInvalidInput + } + return s.listChildItems(ctx, tenant, session, child, turn, after, limit, asc) +} +func (s *Store) listChildItems(ctx context.Context, tenant, session, child, turn, after string, limit int, asc bool) (v1.ItemList, error) { + result := v1.ItemList{Data: []v1.Item{}} + if limit < 1 || limit > 100 { + return result, ErrInvalidInput + } + err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { + if _, err := publicSubagent(ctx, q, sid, child); err != nil { + return err + } + childID, _ := parseID(child) + p := sqlc.ListChildItemsParams{SessionID: sid, SubagentID: childID, Ascending: asc, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, AfterTurn: pgtype.UUID{Valid: true}} + if turn != "" { + row, err := childTurn(ctx, q, sid, child, turn) + if err != nil { + return err + } + p.TurnID = row.ID + } + if after != "" { + id, err := parseID(after) + if err != nil { + return err + } + row, err := q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: sid, SubagentID: childID, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if p.TurnID.Valid && p.TurnID != row.TurnID { + return ErrNotFound + } + p.AfterID = id + p.AfterCreated = row.TurnCreatedAt + p.AfterTurn = row.TurnID + p.AfterPosition = row.Position + } + rows, err := q.ListChildItems(ctx, p) + if err != nil { + return err + } + result.HasMore = len(rows) > limit + if result.HasMore { + rows = rows[:limit] + } + for _, raw := range rows { + var item v1.Item + if err := json.Unmarshal(raw, &item); err != nil { + return err + } + result.Data = append(result.Data, item) + } + return nil + }) + return result, err +} diff --git a/services/agents-api/internal/store/subagent_lifecycle.go b/services/agents-api/internal/store/subagent_lifecycle.go new file mode 100644 index 000000000..a6560b342 --- /dev/null +++ b/services/agents-api/internal/store/subagent_lifecycle.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func validNativeIdentity(value string) bool { + return value != "" && len(value) <= 512 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n") +} +func recordSubagentChange(ctx context.Context, q *sqlc.Queries, session, id pgtype.UUID, kind string) error { + value, err := publicSubagent(ctx, q, session, uuid.UUID(id.Bytes).String()) + if err != nil { + return err + } + return recordSessionChange(ctx, q, session, SessionChange{Event: v1.SessionEvent{Type: "agent.session.subagent." + kind, Subagent: &value}}) +} +func publishSubagent(ctx context.Context, q *sqlc.Queries, session, id pgtype.UUID, identity proto.SubagentIdentityPayload) error { + previous, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: identity.NativeID}) + if err != nil { + return err + } + // Identity metadata is immutable after its first public observation. + if previous.PublicVisible { + return nil + } + p := sqlc.PublishSubagentParams{SessionID: session, ID: id} + if identity.Name != nil { + p.Name = pgtype.Text{String: *identity.Name, Valid: true} + } + if identity.Instructions != nil { + p.Instructions = pgtype.Text{String: *identity.Instructions, Valid: true} + } + if err := q.PublishSubagent(ctx, p); err != nil { + return err + } + return recordSubagentChange(ctx, q, session, id, "created") +} +func projectSubagentLifecycle(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { + var p proto.SubagentLifecyclePayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.EffectID) || p.OccurredAtMS <= 0 || (p.Status != "active" && p.Status != "closed") { + return ErrInvalidInput + } + child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) + if err != nil { + return err + } + if !child.PublicVisible || p.OccurredAtMS < child.NativeCreatedAt*1000 { + return ErrInvalidInput + } + inserted, err := q.PutSubagentEffect(ctx, sqlc.PutSubagentEffectParams{SessionID: session, EffectID: p.EffectID, Payload: raw}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrIdempotencyConflict + } + if err != nil || !inserted { + return err + } + if p.OccurredAtMS < child.LifecycleAtMs { + return ErrIdempotencyConflict + } + if p.Status == child.Status { + return nil + } + closed := pgtype.Int8{} + if p.Status == "closed" { + closed = pgtype.Int8{Int64: p.OccurredAtMS, Valid: true} + } + if err = q.ApplySubagentLifecycle(ctx, sqlc.ApplySubagentLifecycleParams{SessionID: session, ID: child.ID, Status: p.Status, ClosedAtMs: closed, LifecycleAtMs: p.OccurredAtMS}); err != nil { + return err + } + return recordSubagentChange(ctx, q, session, child.ID, p.Status) +} diff --git a/services/agents-api/internal/store/subagent_native_outputs_test.go b/services/agents-api/internal/store/subagent_native_outputs_test.go new file mode 100644 index 000000000..bcb361a57 --- /dev/null +++ b/services/agents-api/internal/store/subagent_native_outputs_test.go @@ -0,0 +1,151 @@ +package store + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { + s, _ := testStore(t) + owner := executionLease(t, s).Store() + tenant, session := newSubagentSession(t, s) + host, err := s.CreateDevice(t.Context(), tenant, "child outputs", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = owner.BindSessionDevice(t.Context(), tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, session.ID, "start") + transition(t, owner, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + call := json.RawMessage(`{"id":"native-file-change","stage":"after","observation":{"status":"completed","kind":"function","name":"apply_patch","arguments":{"count":9007199254740993,"scale":1e2},"content":[{"type":"input_text","text":"file written"}]}}`) + text := "child answer" + message, _ := json.Marshal(proto.OutputMessagePayload{ID: "answer", Status: "completed", Text: &text}) + facts := []ExecutionEvent{ + subagentIdentityEvent("child", "root", 100), + subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: TurnInProgress, CreatedAtMS: 100000}), + subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "native-file-change", Position: 0, Kind: proto.TypeToolCall, Payload: call}), + subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "answer", Position: 1, Kind: proto.TypeOutputMessage, Payload: message}), + } + if err = owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { + t.Fatal(err) + } + finished := int64(101000) + terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: TurnCompleted, CreatedAtMS: 100000, CompletedAtMS: &finished}) + if err = owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 5, []ExecutionEvent{terminal, facts[2], facts[3]}); err != nil { + t.Fatal("identical native tool history must survive replay after completion", err) + } + child, err := s.GetSubagentIdentity(t.Context(), tenant, session.ID, "child") + if err != nil { + t.Fatal(err) + } + items, err := s.ListSubagentItems(t.Context(), tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(items.Data) != 3 { + t.Fatal(items, err) + } + events, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + found := map[string]bool{} + for _, change := range events { + e := change.Event + if e.Item == nil || e.Item.TurnID == input.TurnID { + continue + } + found[e.Item.Type] = true + switch e.Item.Type { + case "function_call_output": + if e.OutputIndex != nil { + t.Fatal("native tool result consumed output index", e) + } + case "function_call": + if e.OutputIndex == nil || *e.OutputIndex != 0 { + t.Fatal(e) + } + case "message": + if e.OutputIndex == nil || *e.OutputIndex != 1 { + t.Fatal(e) + } + } + } + if len(found) != 3 { + t.Fatal(found) + } +} + +func TestSubagentCancelledPartialMessageSurvivesHistoryReplay(t *testing.T) { + s, _ := testStore(t) + owner := executionLease(t, s).Store() + tenant, session := newSubagentSession(t, s) + host, err := s.CreateDevice(t.Context(), tenant, "cancelled child", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = owner.BindSessionDevice(t.Context(), tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + input := submitMessage(t, s, tenant, session.ID, "start") + transition(t, owner, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + message := subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{ + NativeID: "child", TurnID: "child-turn", ItemID: "partial", Kind: proto.TypeOutputMessage, + Payload: json.RawMessage(`{"id":"partial","status":"incomplete","text":"Partial native answer"}`), + }) + finished := int64(101000) + terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: TurnCancelled, CreatedAtMS: 100000, CompletedAtMS: &finished}) + facts := []ExecutionEvent{ + subagentIdentityEvent("child", "root", 100), + subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "child-turn", Status: TurnInProgress, CreatedAtMS: 100000}), + message, terminal, + // A cold history read must preserve the partial answer without re-execution. + message, terminal, + } + if err := owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { + t.Fatal(err) + } + child, err := s.GetSubagentIdentity(t.Context(), tenant, session.ID, "child") + if err != nil { + t.Fatal(err) + } + items, err := s.ListSubagentItems(t.Context(), tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(items.Data) != 1 || items.Data[0].Status != "incomplete" || *items.Data[0].Content[0].Text != "Partial native answer" { + t.Fatal(items, err) + } + turns, err := s.ListSubagentTurns(t.Context(), tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(turns.Data) != 1 || turns.Data[0].Status != TurnCancelled { + t.Fatal(turns, err) + } +} + +func TestSubagentRootCompletionRetainsNativeSourceTime(t *testing.T) { + for _, status := range []string{TurnCompleted, TurnCancelled} { + t.Run(status, func(t *testing.T) { + s, _ := testStore(t) + tenant, session := newTurnSession(t, s) + input := submitMessage(t, s, tenant, session.ID, "start") + transition(t, s, tenant, session.ID, input.TurnID, TurnQueued, TurnInProgress) + current, err := s.GetTurn(t.Context(), tenant, session.ID, input.TurnID) + if err != nil { + t.Fatal(err) + } + source := current.CreatedAt.Unix() * 1000 + outcome := json.RawMessage(fmt.Sprintf(`{"done":{"source_completed_at_ms":%d}}`, source)) + completed, err := s.CompleteExecution(t.Context(), tenant, session.ID, input.TurnID, status, outcome, "", input.Sequence) + if err != nil { + t.Fatal(err) + } + if status == TurnCompleted && !completed.CompletedAt.Equal(time.UnixMilli(source)) { + t.Fatal("child drain changed root source completion", completed.CompletedAt) + } + if status == TurnCancelled && !completed.CompletedAt.After(time.UnixMilli(source)) { + t.Fatal("cancellation reused native success timestamp", completed.CompletedAt) + } + }) + } +} diff --git a/services/agents-api/internal/store/subagent_reads.go b/services/agents-api/internal/store/subagent_reads.go new file mode 100644 index 000000000..bf6612578 --- /dev/null +++ b/services/agents-api/internal/store/subagent_reads.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func publicSubagent(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, id string) (v1.Subagent, error) { + parsed, err := parseID(id) + if err != nil { + return v1.Subagent{}, err + } + row, err := q.GetPublicSubagent(ctx, sqlc.GetPublicSubagentParams{SessionID: session, ID: parsed}) + if errors.Is(err, pgx.ErrNoRows) { + return v1.Subagent{}, ErrNotFound + } + if err != nil { + return v1.Subagent{}, err + } + result := v1.Subagent{ID: id, SessionID: uuid.UUID(session.Bytes).String(), ParentAgentID: row.ParentAgentID, Object: "agent.session.subagent", Status: row.Status, OpenedAt: row.NativeCreatedAt} + if row.Name.Valid { + result.Name = &row.Name.String + } + if row.Instructions.Valid { + result.Instructions = []v1.AgentContent{{Type: "output_text", Text: &row.Instructions.String}} + } + if row.ClosedAtMs.Valid { + seconds := row.ClosedAtMs.Int64 / 1000 + result.ClosedAt = &seconds + } + return result, nil +} + +func (s *Store) GetSubagent(ctx context.Context, tenant, session, id string) (v1.Subagent, error) { + var result v1.Subagent + err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { + var err error + result, err = publicSubagent(ctx, q, sid, id) + return err + }) + return result, err +} + +func (s *Store) ListSubagents(ctx context.Context, tenant, session, after string, limit int, asc bool) (v1.SubagentList, error) { + result := v1.SubagentList{Data: []v1.Subagent{}} + if limit < 1 || limit > 100 { + return result, ErrInvalidInput + } + err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { + p := sqlc.ListPublicSubagentsParams{SessionID: sid, Ascending: asc, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}} + if after != "" { + cursor, err := publicSubagent(ctx, q, sid, after) + if err != nil { + return err + } + p.AfterOpened = pgtype.Int8{Int64: cursor.OpenedAt, Valid: true} + p.AfterID, _ = parseID(after) + } + rows, err := q.ListPublicSubagents(ctx, p) + if err != nil { + return err + } + result.HasMore = len(rows) > limit + if result.HasMore { + rows = rows[:limit] + } + for _, id := range rows { + value, err := publicSubagent(ctx, q, sid, uuid.UUID(id.Bytes).String()) + if err != nil { + return err + } + result.Data = append(result.Data, value) + } + return nil + }) + return result, err +} + +func childTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, child, id string) (sqlc.SubagentTurn, error) { + parsed, err := parseID(id) + if err != nil { + return sqlc.SubagentTurn{}, err + } + row, err := q.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: session, ID: parsed}) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && uuid.UUID(row.SubagentID.Bytes).String() != child) { + return row, ErrNotFound + } + return row, err +} + +func publicChildTurn(row sqlc.SubagentTurn) v1.Turn { + child := uuid.UUID(row.SubagentID.Bytes).String() + value := v1.Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), AgentID: child, SubagentID: &child, Object: "agent.session.turn", Status: row.Status, CreatedAt: row.CreatedAt.Time.Unix()} + if row.StartedAt.Valid { + seconds := row.StartedAt.Time.Unix() + value.StartedAt = &seconds + } + if row.CompletedAt.Valid { + seconds := row.CompletedAt.Time.Unix() + value.CompletedAt = &seconds + } + _ = json.Unmarshal(row.TokenUsage, &value.Usage) + if row.Status == TurnFailed { + value.Error = &v1.TurnError{Code: "internal_error", Message: "The execution could not complete."} + } + return value +} + +func (s *Store) GetSubagentTurn(ctx context.Context, tenant, session, child, id string) (v1.Turn, error) { + var result v1.Turn + err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { + if _, err := publicSubagent(ctx, q, sid, child); err != nil { + return err + } + row, err := childTurn(ctx, q, sid, child, id) + if err == nil { + result = publicChildTurn(row) + } + return err + }) + return result, err +} + +func (s *Store) ListSubagentTurns(ctx context.Context, tenant, session, child, after string, limit int, asc bool) (v1.TurnList, error) { + result := v1.TurnList{Data: []v1.Turn{}} + if limit < 1 || limit > 100 { + return result, ErrInvalidInput + } + err := s.withPublicSession(ctx, tenant, session, func(ctx context.Context, q *sqlc.Queries, sid pgtype.UUID) error { + if _, err := publicSubagent(ctx, q, sid, child); err != nil { + return err + } + childID, _ := parseID(child) + p := sqlc.ListChildTurnsParams{SessionID: sid, SubagentID: childID, Ascending: asc, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}} + if after != "" { + row, err := childTurn(ctx, q, sid, child, after) + if err != nil { + return err + } + p.AfterCreated = row.CreatedAt + p.AfterID = row.ID + } + rows, err := q.ListChildTurns(ctx, p) + if err != nil { + return err + } + result.HasMore = len(rows) > limit + if result.HasMore { + rows = rows[:limit] + } + for _, row := range rows { + result.Data = append(result.Data, publicChildTurn(row)) + } + return nil + }) + return result, err +} diff --git a/services/agents-api/internal/store/subagent_resources_test.go b/services/agents-api/internal/store/subagent_resources_test.go new file mode 100644 index 000000000..d74a0a59e --- /dev/null +++ b/services/agents-api/internal/store/subagent_resources_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func newSubagentSession(t *testing.T, s *Store) (string, Session) { + t.Helper() + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "subagent", Configuration: json.RawMessage(`{"agent":{"id":"agent_root","model":"test","multi_agent":{"enabled":true,"max_concurrent_subagents":6}}}`)}) + if err != nil { + t.Fatal(err) + } + return tenant, session +} +func subagentFact(kind string, value any) ExecutionEvent { + raw, _ := json.Marshal(value) + return ExecutionEvent{Kind: kind, Payload: raw} +} +func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { + s, pool := testStore(t) + owner := executionLease(t, s).Store() + ctx := t.Context() + tenant, session := newSubagentSession(t, s) + host, err := s.CreateDevice(ctx, tenant, "child resources", device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = owner.BindSessionDevice(ctx, tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + root := submitMessage(t, s, tenant, session.ID, "first") + transition(t, owner, tenant, session.ID, root.TurnID, TurnQueued, TurnInProgress) + ordinal := int32(1) + appendFacts := func(facts ...ExecutionEvent) { + t.Helper() + if err := owner.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); err != nil { + t.Fatal(err) + } + ordinal += int32(len(facts)) + } + appendFacts(subagentIdentityEvent("child", "root", 100), subagentIdentityEvent("nested", "child", 101), subagentIdentityEvent("sibling", "root", 100)) + page, err := s.ListSubagents(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(page.Data) != 3 { + t.Fatal(page, err) + } + child, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child") + if err != nil { + t.Fatal(err) + } + nested, _ := s.GetSubagentIdentity(ctx, tenant, session.ID, "nested") + resource, err := s.GetSubagent(ctx, tenant, session.ID, nested.ID) + if err != nil || resource.ParentAgentID != child.ID { + t.Fatal(resource, err) + } + opened := int64(101000) + finished := int64(102000) + turn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn-1", Status: TurnInProgress, CreatedAtMS: opened, StartedAtMS: &opened} + appendFacts(subagentFact(proto.TypeSubagentTurn, turn)) + text := "child-owned-result" + message, _ := json.Marshal(proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}) + item := proto.SubagentItemPayload{NativeID: "child", TurnID: turn.TurnID, ItemID: "native-message", Position: 0, Kind: proto.TypeOutputMessage, Payload: message} + appendFacts(subagentFact(proto.TypeSubagentItem, item)) + turn.Status = TurnCompleted + turn.CompletedAtMS = &finished + appendFacts(subagentFact(proto.TypeSubagentTurn, turn)) + // Later history reads preserve the terminal Turn and all existing Items. + appendFacts(subagentFact(proto.TypeSubagentItem, item), subagentFact(proto.TypeSubagentTurn, turn)) + turns, err := s.ListSubagentTurns(ctx, tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(turns.Data) != 1 { + t.Fatal(turns, err) + } + tid := turns.Data[0].ID + if turns.Data[0].AgentID != child.ID || turns.Data[0].SubagentID == nil || *turns.Data[0].SubagentID != child.ID || turns.Data[0].Usage != nil { + t.Fatal(turns) + } + same, err := s.GetTurn(ctx, tenant, session.ID, tid) + if err != nil || same.SubagentID != child.ID { + t.Fatal(same, err) + } + allTurns, err := s.ListTurns(ctx, tenant, session.ID, "", 100, true) + if err != nil || len(allTurns.Turns) != 2 { + t.Fatal(allTurns, err) + } + own, err := s.ListSubagentTurnItems(ctx, tenant, session.ID, child.ID, tid, "", 20, true) + if err != nil || len(own.Data) != 1 || own.Data[0].TurnID != tid { + t.Fatal(own, err) + } + rootItems, err := s.ListItems(ctx, tenant, session.ID, "", 100, true) + if err != nil { + t.Fatal(err) + } + for _, entry := range rootItems.Items { + if entry.ID == own.Data[0].ID { + t.Fatal("child Item leaked into root Items") + } + } + if _, err = s.GetSubagentTurn(ctx, tenant, session.ID, nested.ID, tid); !errors.Is(err, ErrNotFound) { + t.Fatal("nested ownership", err) + } + if _, err = s.ListSubagentItems(ctx, tenant, session.ID, nested.ID, own.Data[0].ID, 20, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign cursor", err) + } + if _, err = s.GetSubagent(ctx, uuid.NewString(), session.ID, child.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign tenant", err) + } + closed := proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "native-close-1", Status: "closed", OccurredAtMS: 103000} + resumed := proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "native-resume-1", Status: "active", OccurredAtMS: 104000} + appendFacts(subagentFact(proto.TypeSubagentLifecycle, closed), subagentFact(proto.TypeSubagentLifecycle, closed)) + value, err := s.GetSubagent(ctx, tenant, session.ID, child.ID) + if err != nil || value.Status != "closed" || value.ClosedAt == nil || *value.ClosedAt != 103 { + t.Fatal(value, err) + } + appendFacts(subagentFact(proto.TypeSubagentLifecycle, resumed), subagentFact(proto.TypeSubagentLifecycle, resumed)) + reopened := New(pool) + value, err = reopened.GetSubagent(ctx, tenant, session.ID, child.ID) + if err != nil || value.Status != "active" || value.ClosedAt != nil || value.OpenedAt != 100 { + t.Fatal(value, err) + } + changes, err := s.ListSessionEvents(ctx, tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + counts := map[string]int{} + for _, change := range changes { + if change.Event.Subagent != nil && change.Event.Subagent.ID == child.ID { + counts[change.Event.Type]++ + } + } + for _, kind := range []string{"created", "closed", "active"} { + if counts["agent.session.subagent."+kind] != 1 { + t.Fatal(counts) + } + } + // A conflicting replay rolls back the whole batch, including an earlier new child. + closed.OccurredAtMS++ + facts := []ExecutionEvent{subagentIdentityEvent("rollback", "root", 104), subagentFact(proto.TypeSubagentLifecycle, closed)} + if err = owner.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + if _, err = s.GetSubagentIdentity(ctx, tenant, session.ID, "rollback"); !errors.Is(err, ErrNotFound) { + t.Fatal("non-atomic batch", err) + } + // Reads do not invoke native processes, including after the root finishes. + transition(t, owner, tenant, session.ID, root.TurnID, TurnInProgress, TurnCompleted) + if _, err = reopened.GetSubagentTurn(context.Background(), tenant, session.ID, child.ID, tid); err != nil { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/store/subagent_turn_projection.go b/services/agents-api/internal/store/subagent_turn_projection.go new file mode 100644 index 000000000..c6a7bbf5b --- /dev/null +++ b/services/agents-api/internal/store/subagent_turn_projection.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/items" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func nativeMillis(value *int64) pgtype.Timestamptz { + if value == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: time.UnixMilli(*value), Valid: true} +} +func childStoreTurn(row sqlc.SubagentTurn) Turn { + return Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: uuid.UUID(row.SessionID.Bytes).String(), SubagentID: uuid.UUID(row.SubagentID.Bytes).String(), Status: row.Status, CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, Usage: row.TokenUsage} +} +func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { + var p proto.SubagentTurnPayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || p.CreatedAtMS <= 0 { + return ErrInvalidInput + } + if p.Status != TurnQueued && p.Status != TurnInProgress && p.Status != TurnWaiting && !terminalStatus(p.Status) { + return ErrInvalidInput + } + if terminalStatus(p.Status) != (p.CompletedAtMS != nil) { + return ErrInvalidInput + } + if (p.StartedAtMS != nil && *p.StartedAtMS < p.CreatedAtMS) || (p.CompletedAtMS != nil && (*p.CompletedAtMS < p.CreatedAtMS || (p.StartedAtMS != nil && *p.CompletedAtMS < *p.StartedAtMS))) { + return ErrInvalidInput + } + child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) + if err != nil { + return err + } + if !child.PublicVisible { + return ErrInvalidInput + } + id, _ := parseID(items.Identity(uuid.UUID(child.ID.Bytes).String(), "turn:"+p.TurnID)) + old, err := q.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: session, ID: id}) + fresh := errors.Is(err, pgx.ErrNoRows) + if err != nil && !fresh { + return err + } + usage := []byte(nil) + if p.Usage != nil { + value, _ := json.Marshal(p.Usage) + if measured := measuredUsage("usage", value); measured != nil { + usage, _ = json.Marshal(measured) + } else { + return ErrInvalidInput + } + } + // Re-reading native history cannot reopen or mutate a completed child Turn. + if !fresh && terminalStatus(old.Status) { + if !terminalStatus(p.Status) { + return nil + } + var previousUsage, nextUsage *v1.TokenUsage + _ = json.Unmarshal(old.TokenUsage, &previousUsage) + _ = json.Unmarshal(usage, &nextUsage) + if old.Status != p.Status || old.CompletedAt.Time.UnixMilli() != *p.CompletedAtMS || !reflect.DeepEqual(previousUsage, nextUsage) { + return ErrIdempotencyConflict + } + return nil + } + if !fresh && old.Status != p.Status && !validTransition(old.Status, p.Status) { + return ErrTurnConflict + } + row, err := q.PutChildTurn(ctx, sqlc.PutChildTurnParams{ID: id, SessionID: session, SubagentID: child.ID, NativeID: p.TurnID, Status: p.Status, CreatedAt: pgtype.Timestamptz{Time: time.UnixMilli(p.CreatedAtMS), Valid: true}, StartedAt: nativeMillis(p.StartedAtMS), CompletedAt: nativeMillis(p.CompletedAtMS), TokenUsage: usage}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrIdempotencyConflict + } + if err != nil { + return err + } + value := publicChildTurn(row) + emit := func(kind string) error { + return recordSessionChange(ctx, q, session, SessionChange{Event: v1.SessionEvent{Type: "agent.session.turn." + kind, TurnID: value.ID, Turn: &value}}) + } + if fresh { + if err := emit("created"); err != nil { + return err + } + } + if (fresh || old.Status != row.Status) && row.Status != TurnWaiting { + return emit(row.Status) + } + return nil +} diff --git a/services/agents-api/internal/store/token_usage.go b/services/agents-api/internal/store/token_usage.go index 71a202961..8107b75b4 100644 --- a/services/agents-api/internal/store/token_usage.go +++ b/services/agents-api/internal/store/token_usage.go @@ -15,6 +15,16 @@ func projectSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UU if kind == proto.TypeSubagentIdentity { return projectSubagentIdentity(ctx, q, session, turn, int32(sequence), raw) } + switch kind { + case proto.TypeSubagentLifecycle: + return projectSubagentLifecycle(ctx, q, session, raw) + case proto.TypeSubagentTurn: + return projectSubagentTurn(ctx, q, session, raw) + case proto.TypeSubagentItem: + return projectSubagentItem(ctx, q, session, raw) + case proto.TypeSubagentCoordination: + return projectRootCoordination(ctx, q, session, turn, raw, created) + } if usage := measuredUsage(kind, raw); usage != nil { payload, err := json.Marshal(usage) if err != nil { diff --git a/services/agents-api/internal/store/turn_completion.go b/services/agents-api/internal/store/turn_completion.go index e829aeaa6..0c8d034cb 100644 --- a/services/agents-api/internal/store/turn_completion.go +++ b/services/agents-api/internal/store/turn_completion.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "time" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5" @@ -45,7 +46,25 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn return ErrUnappliedInputs } } - row, err = q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ID: p.ID, SessionID: session, ExpectedStatus: current.Status, NewStatus: status, Outcome: outcome}) + sourceCompleted := pgtype.Timestamptz{} + if status == TurnCompleted { + var snapshot struct { + Done *struct { + SourceCompletedAtMS *int64 `json:"source_completed_at_ms"` + } `json:"done"` + } + if json.Unmarshal(outcome, &snapshot) != nil { + return ErrInvalidInput + } + if snapshot.Done != nil && snapshot.Done.SourceCompletedAtMS != nil { + ms := *snapshot.Done.SourceCompletedAtMS + if ms <= 0 || ms/1000 < current.CreatedAt.Time.Unix() { + return ErrInvalidInput + } + sourceCompleted = pgtype.Timestamptz{Time: time.UnixMilli(ms), Valid: true} + } + } + row, err = q.TransitionTurn(ctx, sqlc.TransitionTurnParams{ID: p.ID, SessionID: session, ExpectedStatus: current.Status, NewStatus: status, Outcome: outcome, SourceCompletedAt: sourceCompleted}) if errors.Is(err, pgx.ErrNoRows) { return ErrTurnConflict } diff --git a/services/agents-api/internal/store/turn_events.go b/services/agents-api/internal/store/turn_events.go index 96eab6f7b..730f2bfe3 100644 --- a/services/agents-api/internal/store/turn_events.go +++ b/services/agents-api/internal/store/turn_events.go @@ -6,7 +6,6 @@ import ( "errors" "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -38,7 +37,7 @@ func (s *Store) AppendTurnEvents(ctx context.Context, tenantID, sessionID, turnI normalized := make([]ExecutionEvent, len(events)) payloadBytes := 0 for i, event := range events { - if event.Kind == proto.TypeSubagentIdentity && s.executionLease == nil { + if isSubagentObservation(event.Kind) && s.executionLease == nil { return errors.New("subagent discovery requires a leased Store") } if len(event.Payload) > 512*1024 || !enginePattern.MatchString(event.Kind) { diff --git a/services/agents-api/internal/store/turn_reads.go b/services/agents-api/internal/store/turn_reads.go index 6d34efb57..83fec7d09 100644 --- a/services/agents-api/internal/store/turn_reads.go +++ b/services/agents-api/internal/store/turn_reads.go @@ -42,7 +42,13 @@ func (s *Store) ListTurns(ctx context.Context, tenantID, sessionID, cursor strin rows = rows[:limit] } for _, row := range rows { - page.Turns = append(page.Turns, turnFromRow(row)) + value := Turn{ID: uuid.UUID(row.ID.Bytes).String(), SessionID: sessionID, Status: row.Status, + CreatedAt: row.CreatedAt.Time, StartedAt: row.StartedAt.Time, CompletedAt: row.CompletedAt.Time, + CancelRequestedAt: row.CancelRequestedAt.Time, Outcome: row.Outcome, Usage: row.TokenUsage, ArtifactCaptureStarted: row.ArtifactCaptureStarted} + if row.SubagentID.Valid { + value.SubagentID = uuid.UUID(row.SubagentID.Bytes).String() + } + page.Turns = append(page.Turns, value) } return page, nil } diff --git a/services/agents-api/internal/store/turns.go b/services/agents-api/internal/store/turns.go index e301acfa8..5edc6f243 100644 --- a/services/agents-api/internal/store/turns.go +++ b/services/agents-api/internal/store/turns.go @@ -30,6 +30,7 @@ const ( // not an upstream response; the API must project supported wire types explicitly. type Turn struct { ID, SessionID, Status string + SubagentID string CreatedAt time.Time StartedAt time.Time CompletedAt time.Time @@ -53,7 +54,18 @@ func (s *Store) GetTurn(ctx context.Context, tenantID, sessionID, turnID string) } row, err := s.queries.GetTurn(ctx, params) if errors.Is(err, pgx.ErrNoRows) { - return Turn{}, ErrNotFound + // Native child work has a separate writer and never enters the Core queue. + if _, err := s.GetSession(ctx, tenantID, sessionID); err != nil { + return Turn{}, err + } + child, err := s.queries.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: params.SessionID, ID: params.ID}) + if errors.Is(err, pgx.ErrNoRows) { + return Turn{}, ErrNotFound + } + if err != nil { + return Turn{}, err + } + return childStoreTurn(child), nil } if err != nil { return Turn{}, fmt.Errorf("get turn: %w", err) diff --git a/services/agents-api/migrations/000051_subagent_resources.sql b/services/agents-api/migrations/000051_subagent_resources.sql new file mode 100644 index 000000000..da1e7c15e --- /dev/null +++ b/services/agents-api/migrations/000051_subagent_resources.sql @@ -0,0 +1,66 @@ +-- +goose Up +ALTER TABLE subagent_identities + ADD COLUMN name text, + ADD COLUMN instructions text, + ADD COLUMN public_visible boolean NOT NULL DEFAULT false, + ADD COLUMN status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')), + ADD COLUMN closed_at_ms bigint, + ADD COLUMN lifecycle_at_ms bigint NOT NULL DEFAULT 0, + ADD CONSTRAINT subagent_closed_state CHECK ((status = 'closed') = (closed_at_ms IS NOT NULL)), + ADD CONSTRAINT subagent_session_identity UNIQUE (session_id, id); + +-- Child work is observed from the native owner; it must never enter the Core queue. +CREATE TABLE subagent_turns ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL, + subagent_id uuid NOT NULL, + native_id text NOT NULL CHECK (native_id <> ''), + status text NOT NULL CHECK (status IN ('queued', 'in_progress', 'waiting', 'completed', 'failed', 'cancelled')), + created_at timestamptz NOT NULL, + started_at timestamptz, + completed_at timestamptz, + token_usage jsonb, + FOREIGN KEY (session_id, subagent_id) REFERENCES subagent_identities(session_id, id), + UNIQUE (subagent_id, native_id), + UNIQUE (session_id, subagent_id, id), + CHECK ((completed_at IS NOT NULL) = (status IN ('completed', 'failed', 'cancelled'))) +); +CREATE TABLE subagent_items ( + id uuid PRIMARY KEY, + session_id uuid NOT NULL, + subagent_id uuid NOT NULL, + turn_id uuid NOT NULL, + position integer NOT NULL CHECK (position >= 0), + output_index integer, + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + FOREIGN KEY (session_id, subagent_id, turn_id) REFERENCES subagent_turns(session_id, subagent_id, id), + UNIQUE (turn_id, position) +); +CREATE TABLE subagent_effects ( + session_id uuid NOT NULL REFERENCES sessions(id), + effect_id text NOT NULL, + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + PRIMARY KEY (session_id, effect_id) +); + +CREATE VIEW public_execution_turns AS + SELECT id, session_id, status, created_at, started_at, completed_at, cancel_requested_at, outcome, + token_usage, artifact_capture_started, NULL::uuid AS subagent_id FROM turns + UNION ALL + SELECT id, session_id, status, created_at, started_at, completed_at, NULL::timestamptz, + '{}'::jsonb, token_usage, false, subagent_id FROM subagent_turns; + +-- +goose Down +DROP VIEW public_execution_turns; +DROP TABLE subagent_effects; +DROP TABLE subagent_items; +DROP TABLE subagent_turns; +ALTER TABLE subagent_identities + DROP CONSTRAINT subagent_session_identity, + DROP CONSTRAINT subagent_closed_state, + DROP COLUMN lifecycle_at_ms, + DROP COLUMN closed_at_ms, + DROP COLUMN status, + DROP COLUMN public_visible, + DROP COLUMN instructions, + DROP COLUMN name;