diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index bc955df4e..b19976692 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -30,7 +30,7 @@ A function declaration requires `name`, `description` and a JSON Schema in `para The native Harness can abandon a call before it applies a result, for example when the root Turn closes or interrupts the Subagent that made it, or when MiniMax Code's MCP client times the call out. The call then stops being a required action, the Turn continues and returns to `in_progress` when no required action is left, and a later `function_call_output` for it is rejected like a result after cancellation. An abandoned call has no application receipt. -A function call made by a Subagent is a required action of the root Turn that runs the child. Its `turn_id` is that root Turn, and its `call_id` equals the `call_id` of the child's `function_call` Item on the [Subagent routes](./subagents.md#public-reads). Answer it with `function_call_output` / `tool_result` under that `call_id` and the root Turn ID; the child's Turn ID is not a Session Turn and gets the 400 for a call of another Turn. A Subagent's HTTP MCP calls are `mcp_call` Items in the child's history. +A function call made by a Subagent is a required action of the root Turn that runs the child. Its `turn_id` is that root Turn, and its `call_id` equals the `call_id` of the child's `function_call` Item on the [Subagent routes](./subagents.md#public-reads). Answer it with `function_call_output` / `tool_result` under that `call_id` and either the root Turn ID or the child Turn ID that the call's `agent.session.turn.item.added` event names, which is what the official SDK sends; any other Turn ID gets the 400 for a call of another Turn. A Subagent's HTTP MCP calls are `mcp_call` Items in the child's history. ### Required actions and recovery diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index c0a4ee7cf..f653f559a 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -162,7 +162,7 @@ Each item is Core's deliberate or native behavior where the official service beh ### Unverified against the official service - The order of errors when one request has several faults, and error, default and payload-limit parity in general. -- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. +- A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. - Whether Subagent child Turns and pending Environment file writes block Session deletion. - The official order between the pending-input error and an unknown result target. - Failure reasons for npm, initial-file and Skill installation have no official sample. diff --git a/contracts/agents-api/subagents.md b/contracts/agents-api/subagents.md index 54eafb74e..483fcefab 100644 --- a/contracts/agents-api/subagents.md +++ b/contracts/agents-api/subagents.md @@ -24,7 +24,7 @@ Lists use `after`, `limit` (default 20) and `order` (default `desc`) and return Child work appears only on the Subagent routes. - Session `turns.list` and `turns.retrieve` return root Turns only. A child Turn ID, as a path or a list cursor, returns the same 404 as a missing Turn; Core's 404 message differs from the official one. -- The Session event stream and the creation stream carry root work only: child Turns and their Items publish no `agent.session.turn.*` or Item content events. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle state. +- The Session event stream and the creation stream carry root work only: child Turns and their Items publish no `agent.session.turn.*` or Item content events, except that a Subagent's function call publishes its `function_call` Item once as `agent.session.turn.item.added`, with the child Turn ID as `turn_id`. `agent.session.subagent.*` events and root coordination Items stay. The creation stream still ends on the root's settled idle state. - A child Turn's `agent_id` is the Session's Agent ID (a direct child's `parent_agent_id` and the `create_subagent_call` `agent_id`); `subagent_id` identifies the child. Nested children follow the same rule. Root Turns have `subagent_id: null`. - Session Items stay root-owned; inherited native parent transcripts are not child work. Session usage sums root Turns only. diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 8b5430064..cebd341ca 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: d88acdb94153f3974e66adbdb9443706ae47adef280cae71b03fdf896bdfe579 +source_hash: 44b540ff6827f818d8bc4cbaa6dc5fc9929bbf82a15c66edeb604909ecb6b110 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -32,7 +32,7 @@ Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 原生 Harness 可能在应用结果之前放弃调用,例如根 Turn 关闭或中断了发起该调用的 Subagent,或 MiniMax Code 的 MCP 客户端使该调用超时。该调用随即不再是待执行操作,Turn 继续运行,并在没有待执行操作时回到 `in_progress`;之后为它提交的 `function_call_output` 会像取消后的结果一样被拒绝。被放弃的调用没有应用回执。 -Subagent 发起的函数调用是运行该子级的根 Turn 的待执行操作:`turn_id` 是该根 Turn,`call_id` 等于 [Subagent 路由](subagents.md#public-reads)上该子级 `function_call` Item 的 `call_id`。使用该 `call_id` 和根 Turn ID,以 `function_call_output` / `tool_result` 作答;子级的 Turn ID 不是 Session Turn,会得到“另一个 Turn 的调用”的 400。Subagent 的 HTTP MCP 调用是该子级历史中的 `mcp_call` Item。 +Subagent 发起的函数调用是运行该子级的根 Turn 的待执行操作:`turn_id` 是该根 Turn,`call_id` 等于 [Subagent 路由](subagents.md#public-reads)上该子级 `function_call` Item 的 `call_id`。使用该 `call_id`,以及根 Turn ID 或该调用的 `agent.session.turn.item.added` 事件所标明的子级 Turn ID(官方 SDK 发送的即是后者),以 `function_call_output` / `tool_result` 作答;其他 Turn ID 会得到“另一个 Turn 的调用”的 400。Subagent 的 HTTP MCP 调用是该子级历史中的 `mcp_call` Item。 ### 待执行操作与恢复 {#required-actions-and-recovery} diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 053bd7ea6..cfa35b171 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 025c67e723ae2e055c4674f5c721cb51bd2b59899f907ccc05b27ce70360f142 +source_hash: ae8f903f342adedb090dc95ff325796adef0c25f6ff1e67478b3c312901b28ee --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -165,7 +165,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh ### 尚未与官方服务核实的内容 {#unverified-against-the-official-service} - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 -- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 +- Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 - Subagent 子级 Turns 和待处理的 Environment file 写入是否会阻止 Session 删除。 - 官方服务在待处理输入错误与未知结果目标之间的先后顺序。 - npm、initial-file 和 Skill 安装的失败原因没有官方样本。 diff --git a/contracts/agents-api/zh/subagents.md b/contracts/agents-api/zh/subagents.md index 761b4fc9e..b05ad9895 100644 --- a/contracts/agents-api/zh/subagents.md +++ b/contracts/agents-api/zh/subagents.md @@ -1,7 +1,7 @@ --- title: "子 Agent" source: contracts/agents-api/subagents.md -source_hash: d8341bfd7747152dea23afc930cf5fe07ff1ac4a58f503cb0e2ec5fe86060cb9 +source_hash: 11575ce73f7f00cb2fefd775d97899ac2439f04e0655a4980269bdb982740ad7 --- 启用 `multi_agent.enabled` 后,Harness 可以启动原生子 Agent。Core 通过锁定版本的 SDK(见 [`upstream.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))中的六项 Subagent 读取操作公开它们,并根据适配器观察结果记录它们。当 `multi_agent.enabled=false` 时,Runtime 会移除原生子 Agent 工具。[Harness capabilities](harness-capabilities.md) 列出各 Harness 在哪些组合中支持子 Agent。 @@ -26,7 +26,7 @@ source_hash: d8341bfd7747152dea23afc930cf5fe07ff1ac4a58f503cb0e2ec5fe86060cb9 子级工作仅出现在子 Agent 路由上。 - Session 的 `turns.list` 和 `turns.retrieve` 仅返回根级 Turns。将子级 Turn ID 作为路径参数或列表游标使用时,会返回与不存在的 Turn 相同的 404;Core 的 404 消息与官方消息不同。 -- Session 事件流和创建流仅传输根级工作:子级 Turns 及其 Items 不会发布任何 `agent.session.turn.*` 或 Item 内容事件。`agent.session.subagent.*` 事件和根级协调 Items 仍会保留。创建流仍会在根级收敛到 idle 状态时结束。 +- Session 事件流和创建流仅传输根级工作:子级 Turns 及其 Items 不会发布任何 `agent.session.turn.*` 或 Item 内容事件,但 Subagent 的函数调用会将其 `function_call` Item 作为 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID。`agent.session.subagent.*` 事件和根级协调 Items 仍会保留。创建流仍会在根级收敛到 idle 状态时结束。 - 子级 Turn 的 `agent_id` 是 Session 的 Agent ID(即直接子级的 `parent_agent_id`,也是 `create_subagent_call` 的 `agent_id`);`subagent_id` 标识该子级。嵌套子级遵循相同规则。根级 Turns 带有 `subagent_id: null`。 - Session Items 始终归根级所有;继承的原生父级对话记录不属于子级工作。Session 用量仅汇总根级 Turns。 diff --git a/services/core/internal/db/queries/functions.sql b/services/core/internal/db/queries/functions.sql index 783d67234..e9b6298bb 100644 --- a/services/core/internal/db/queries/functions.sql +++ b/services/core/internal/db/queries/functions.sql @@ -23,6 +23,9 @@ SELECT EXISTS ( SELECT 1 FROM function_calls WHERE session_id = sqlc.arg(session_id) AND call_id = sqlc.arg(call_id) )::boolean AS found; +-- name: FindFunctionCallTurn :one +SELECT turn_id, executor_call_id FROM function_calls WHERE session_id = $1 AND call_id = $2; + -- name: MatchFunctionResult :one SELECT (result IS NOT NULL)::boolean AS submitted, COALESCE(result = sqlc.arg(result)::jsonb, false)::boolean AS matches, withdrawn diff --git a/services/core/internal/db/sqlc/functions.sql.go b/services/core/internal/db/sqlc/functions.sql.go index 887a84cf7..fcb3aeb2b 100644 --- a/services/core/internal/db/sqlc/functions.sql.go +++ b/services/core/internal/db/sqlc/functions.sql.go @@ -55,6 +55,27 @@ func (q *Queries) CreateFunctionCall(ctx context.Context, arg CreateFunctionCall return result.RowsAffected(), nil } +const findFunctionCallTurn = `-- name: FindFunctionCallTurn :one +SELECT turn_id, executor_call_id FROM function_calls WHERE session_id = $1 AND call_id = $2 +` + +type FindFunctionCallTurnParams struct { + SessionID pgtype.UUID `json:"session_id"` + CallID string `json:"call_id"` +} + +type FindFunctionCallTurnRow struct { + TurnID pgtype.UUID `json:"turn_id"` + ExecutorCallID string `json:"executor_call_id"` +} + +func (q *Queries) FindFunctionCallTurn(ctx context.Context, arg FindFunctionCallTurnParams) (FindFunctionCallTurnRow, error) { + row := q.db.QueryRow(ctx, findFunctionCallTurn, arg.SessionID, arg.CallID) + var i FindFunctionCallTurnRow + err := row.Scan(&i.TurnID, &i.ExecutorCallID) + return i, err +} + const functionItemResult = `-- name: FunctionItemResult :one SELECT result FROM function_calls WHERE session_id = $1 AND turn_id = $2 AND call_id = $3 diff --git a/services/core/internal/persistence/postgres/sessionpg/functions.go b/services/core/internal/persistence/postgres/sessionpg/functions.go index 3ee5cb2d8..2f4b90131 100644 --- a/services/core/internal/persistence/postgres/sessionpg/functions.go +++ b/services/core/internal/persistence/postgres/sessionpg/functions.go @@ -71,6 +71,21 @@ func (t *SessionTx) FindResultTurn(ctx context.Context, turn string) (sessions.T return TurnFromRow(row), true, nil } +func (t *SessionTx) FindCallTurn(ctx context.Context, call string) (sessions.Turn, string, bool, error) { + row, err := t.q.FindFunctionCallTurn(ctx, sqlc.FindFunctionCallTurnParams{SessionID: t.session, CallID: call}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.Turn{}, "", false, nil + } + if err != nil { + return sessions.Turn{}, "", false, err + } + root, err := t.q.SessionEventTurn(ctx, sqlc.SessionEventTurnParams{SessionID: t.session, ID: row.TurnID}) + if err != nil { + return sessions.Turn{}, "", false, err + } + return TurnFromRow(root), row.ExecutorCallID, true, nil +} + func (t *SessionTx) MatchFunctionResult(ctx context.Context, turn, call string, result json.RawMessage) (sessions.FunctionResultMatch, error) { id, err := parseID(turn) if err != nil { diff --git a/services/core/internal/sessions/execution_functions.go b/services/core/internal/sessions/execution_functions.go index 9495cc8fe..1cbe24700 100644 --- a/services/core/internal/sessions/execution_functions.go +++ b/services/core/internal/sessions/execution_functions.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) @@ -95,33 +97,35 @@ func decideFunctionWithdrawal(turn Turn, call FunctionCall) (bool, error) { } // resolveFunctionCall gives a call a Subagent made the identity of the child's -// function Item. A child the Session has not published, or a malformed native -// identity, is ErrInvalidInput. -func resolveFunctionCall(ctx context.Context, tx NativeSubagents, call FunctionCall) (FunctionCall, error) { +// function Item, and returns the child Turn's ID, empty for a root call. A +// child the Session has not published, or a malformed native identity, is +// ErrInvalidInput. +func resolveFunctionCall(ctx context.Context, tx NativeSubagents, call FunctionCall) (FunctionCall, string, error) { if call.Subagent == "" && call.SubagentTurn == "" { - return call, nil + return call, "", nil } if !validNativeIdentity(call.Subagent) || !validNativeIdentity(call.SubagentTurn) { - return call, ErrInvalidInput + return call, "", ErrInvalidInput } child, err := visibleSubagent(ctx, tx, call.Subagent) if err != nil { - return call, err + return call, "", err } - call.CallID = items.Identity(items.Identity(child.ID, "turn:"+call.SubagentTurn), "tool:"+call.ExecutorCallID) - return call, nil + turn := items.Identity(child.ID, "turn:"+call.SubagentTurn) + call.CallID = items.Identity(turn, "tool:"+call.ExecutorCallID) + return call, turn, nil } // RecordFunctionCall records a call an executor reported for its Turn and // journals the Turn's required actions with it. A call a Subagent made takes -// the identity of the child's function Item. A retry of the recorded call -// changes nothing. +// the identity of the child's function Item and is published as that Item. A +// retry of the recorded call changes nothing. func (o *ExecutionOperations) RecordFunctionCall(ctx context.Context, tenant, session, turn string, call FunctionCall) error { if err := validateFunctionCall(call); err != nil { return err } return o.storage.WithFunctionTurn(ctx, tenant, session, turn, func(ctx context.Context, tx FunctionTx, current Turn) error { - call, err := resolveFunctionCall(ctx, tx, call) + call, childTurn, err := resolveFunctionCall(ctx, tx, call) if err != nil { return err } @@ -136,6 +140,13 @@ func (o *ExecutionOperations) RecordFunctionCall(ctx context.Context, tenant, se if err := tx.CreateFunctionCall(ctx, current.ID, call); err != nil { return err } + if childTurn != "" { + // The official SDK answers calls from this event; child Items publish nothing else. + item := v1.Item{ID: call.CallID, TurnID: childTurn, Type: "function_call", Status: "in_progress", Name: call.Name, CallID: call.CallID, Arguments: call.Arguments} + if err := tx.AppendChanges(ctx, ItemChanges(items.Change{Item: item}, nil)...); err != nil { + return err + } + } return settleFunctionState(ctx, tx, current) }) } @@ -189,7 +200,7 @@ func (o *ExecutionOperations) WithdrawFunctionCall(ctx context.Context, tenant, return ErrInvalidInput } return o.storage.WithFunctionTurn(ctx, tenant, session, turn, func(ctx context.Context, tx FunctionTx, current Turn) error { - call, err := resolveFunctionCall(ctx, tx, call) + call, _, err := resolveFunctionCall(ctx, tx, call) if err != nil { return err } diff --git a/services/core/internal/sessions/functions.go b/services/core/internal/sessions/functions.go index 5810ef9d4..91376775c 100644 --- a/services/core/internal/sessions/functions.go +++ b/services/core/internal/sessions/functions.go @@ -6,7 +6,10 @@ import ( "fmt" "strings" + "github.com/google/uuid" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" ) @@ -87,6 +90,9 @@ type FunctionResultTx interface { // FindResultTurn reads the Session's Turn that a result names and reports // whether the Session has it. A malformed ID names no Turn. FindResultTurn(ctx context.Context, turn string) (Turn, bool, error) + // FindCallTurn reads the Turn that holds the Session's call, with the + // call's executor identity, and reports whether the Session has the call. + FindCallTurn(ctx context.Context, call string) (Turn, string, bool, error) // MatchFunctionResult compares result with the Turn's call. MatchFunctionResult(ctx context.Context, turn, call string, result json.RawMessage) (FunctionResultMatch, error) // SubmitFunctionResult saves the result of the Turn's call. @@ -99,13 +105,28 @@ type FunctionResultTx interface { // and returns that Turn, which the result's input joins. It never creates a // Turn. A result whose Turn does not have the call is // ErrFunctionCallTurnMismatch when another Turn of the Session has it and -// ErrUnknownFunctionCall otherwise; the answer depends only on the Session's +// ErrUnknownFunctionCall otherwise; a Subagent Turn that made the call stands +// for the root Turn that holds it; the answer depends only on the Session's // own calls. func AdmitFunctionResult(ctx context.Context, tx FunctionResultTx, input FunctionResultInput) (Turn, error) { turn, found, err := tx.FindResultTurn(ctx, input.TurnID) if err != nil { return Turn{}, err } + if _, malformed := uuid.Parse(input.TurnID); !found && malformed == nil { + // A result may name the Subagent Turn that made the call, as the + // call's Session event does. The child's function Item identity is + // derived from its Turn and the call's executor identity, so a match + // proves the call was made there; the result joins the root Turn + // holding the call. + holder, executor, held, err := tx.FindCallTurn(ctx, input.CallID) + if err != nil { + return Turn{}, err + } + if held && items.Identity(input.TurnID, "tool:"+executor) == input.CallID { + turn, found = holder, true + } + } if found { match, err := tx.MatchFunctionResult(ctx, turn.ID, input.CallID, input.Result) if err != nil { diff --git a/services/core/internal/sessions/functions_test.go b/services/core/internal/sessions/functions_test.go index c220b5c48..38726c9fa 100644 --- a/services/core/internal/sessions/functions_test.go +++ b/services/core/internal/sessions/functions_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" "reflect" "strings" "testing" @@ -17,6 +18,7 @@ type fakeFunctionTx struct { loadPendingFunctionCalls func() ([]FunctionCall, error) findResultTurn func() (Turn, bool, error) + findCallTurn func() (Turn, string, bool, error) matchFunctionResult func() (FunctionResultMatch, error) submitFunctionResult func() error hasFunctionCall func() (bool, error) @@ -42,6 +44,11 @@ func (f *fakeFunctionTx) FindResultTurn(_ context.Context, turn string) (Turn, b return f.findResultTurn() } +func (f *fakeFunctionTx) FindCallTurn(_ context.Context, call string) (Turn, string, bool, error) { + f.record("FindCallTurn", f.findCallTurn != nil, call) + return f.findCallTurn() +} + func (f *fakeFunctionTx) MatchFunctionResult(_ context.Context, turn, call string, result json.RawMessage) (FunctionResultMatch, error) { f.record("MatchFunctionResult", f.matchFunctionResult != nil, turn, call, string(result)) return f.matchFunctionResult() @@ -199,6 +206,19 @@ func TestAdmitFunctionResult(t *testing.T) { } assertCalls(t, f.fakeTx, find, match, submit) }) + t.Run("a Subagent Turn stands for the root Turn holding its call", func(t *testing.T) { + const child = "7c1ad0f2-5e0c-4e5e-9b43-0d1c4c6a1f10" + call := items.Identity(child, "tool:native") + for name, executor := range map[string]string{"made there": "native", "made elsewhere": "other"} { + f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, + findCallTurn: func() (Turn, string, bool, error) { return waiting, executor, true, nil }, hasFunctionCall: returns(true), + matchFunctionResult: returns(FunctionResultMatch{Recorded: true}), submitFunctionResult: done} + turn, err := AdmitFunctionResult(t.Context(), f, FunctionResultInput{TurnID: child, CallID: call, Result: input.Result}) + if (name == "made there") != (err == nil) || (err == nil && turn.ID != testTurn) || (err != nil && !errors.Is(err, ErrFunctionCallTurnMismatch)) { + t.Errorf("%s: %v %v", name, turn, err) + } + } + }) t.Run("identical retry keeps the saved result", func(t *testing.T) { completed := turnWith(TurnCompleted) f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, findResultTurn: found(completed), matchFunctionResult: returns(FunctionResultMatch{Recorded: true, Submitted: true, Matches: true})} @@ -235,13 +255,13 @@ func TestAdmitFunctionResult(t *testing.T) { "unknown Turn and call": {false, false, ErrUnknownFunctionCall}, } { f := &fakeFunctionTx{fakeTx: &fakeTx{t: t}, hasFunctionCall: returns(test.elsewhere), matchFunctionResult: returns(FunctionResultMatch{}), - findResultTurn: func() (Turn, bool, error) { return waiting, test.turnFound, nil }} + findResultTurn: func() (Turn, bool, error) { return waiting, test.turnFound, nil }, findCallTurn: func() (Turn, string, bool, error) { return Turn{}, "", false, nil }} if _, err := AdmitFunctionResult(t.Context(), f, input); !errors.Is(err, test.want) { t.Errorf("%s: %v", name, err) } want := []string{find, match, "HasFunctionCall call"} if !test.turnFound { - want = []string{find, "HasFunctionCall call"} + want = []string{find, "FindCallTurn call", "HasFunctionCall call"} } assertCalls(t, f.fakeTx, want...) } @@ -251,7 +271,7 @@ func TestAdmitFunctionResult(t *testing.T) { "find": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, errStorage }}, "match": {fakeTx: &fakeTx{t: t}, findResultTurn: found(waiting), matchFunctionResult: func() (FunctionResultMatch, error) { return FunctionResultMatch{}, errStorage }}, "submit": {fakeTx: &fakeTx{t: t}, findResultTurn: found(waiting), matchFunctionResult: returns(FunctionResultMatch{Recorded: true}), submitFunctionResult: func() error { return errStorage }}, - "classify": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, + "classify": {fakeTx: &fakeTx{t: t}, findResultTurn: func() (Turn, bool, error) { return Turn{}, false, nil }, findCallTurn: func() (Turn, string, bool, error) { return Turn{}, "", false, nil }, hasFunctionCall: func() (bool, error) { return false, errStorage }}, } { if _, err := AdmitFunctionResult(t.Context(), f, input); !errors.Is(err, errStorage) { diff --git a/services/core/tests/integration/function_execution_test.go b/services/core/tests/integration/function_execution_test.go index f81acf33c..e4a163f26 100644 --- a/services/core/tests/integration/function_execution_test.go +++ b/services/core/tests/integration/function_execution_test.go @@ -141,12 +141,9 @@ func TestExecutionSubagentFunctionCallIsARequiredActionOfTheRootTurn(t *testing. if err != nil || state.LastTurn.ID != input.TurnID || state.LastTurn.Status != sessions.TurnWaiting || len(childItems.Data) != 1 || childItems.Data[0].CallID != action.CallID { t.Fatal(state, childItems, err) } - // The child Turn is not a Session Turn, so only the root Turn answers the call. + // The official SDK answers with the child Turn ID of the call's event. answer := json.RawMessage(`{"success":true,"output":"saved"}`) - if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, items.Identity(child.ID, "turn:child-turn"), action.CallID, answer); !errors.Is(err, sessions.ErrFunctionCallTurnMismatch) { - t.Fatal("the child Turn answered the call", err) - } - if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, input.TurnID, action.CallID, answer); err != nil { + if err := SubmitFixtureFunctionResult(t.Context(), h.s, h.tenant, h.session.ID, items.Identity(child.ID, "turn:child-turn"), action.CallID, answer); err != nil { t.Fatal(err) } var reply proto.FunctionResultPayload diff --git a/services/core/tests/integration/subagent_function_call_test.go b/services/core/tests/integration/subagent_function_call_test.go new file mode 100644 index 000000000..1497a3baa --- /dev/null +++ b/services/core/tests/integration/subagent_function_call_test.go @@ -0,0 +1,83 @@ +package integration + +import ( + "encoding/json" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" +) + +func TestSubagentFunctionCallIsPublishedAndAnsweredByChildTurn(t *testing.T) { + s, _ := testStore(t) + w := executionWriter(t, s) + ops := sessionExecution(t, w.lease) + ctx := t.Context() + tenant, session := newSubagentSession(t, s) + host, err := sessionService(t, s).CreateDevice(ctx, tenant, "child call", runtimedevice.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + if err = ops.BindSessionDevice(ctx, tenant, session.ID, host.ID); err != nil { + t.Fatal(err) + } + root := submitMessage(t, s, tenant, session.ID, "start").TurnID + transition(t, w, tenant, session.ID, root, sessions.TurnQueued, sessions.TurnInProgress) + started := int64(101000) + turn := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn", Status: sessions.TurnInProgress, CreatedAtMS: started, StartedAtMS: &started} + if err = ops.AppendTurnEvents(ctx, tenant, session.ID, root, 1, []sessions.ExecutionEvent{subagentIdentityEvent("child", "root", 100), subagentFact(proto.TypeSubagentTurn, turn)}); err != nil { + t.Fatal(err) + } + child, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child") + if err != nil { + t.Fatal(err) + } + turns, err := sessionAdapter(s).ListSubagentTurns(ctx, tenant, session.ID, child.ID, "", 20, true) + if err != nil || len(turns.Data) != 1 { + t.Fatal(turns, err) + } + childTurn := turns.Data[0].ID + before, err := sessionAdapter(s).SessionEventCursor(ctx, tenant, session.ID) + if err != nil { + t.Fatal(err) + } + call := sessions.FunctionCall{CallID: "ignored", ExecutorCallID: "native-call", Name: "lookup", Arguments: json.RawMessage(`{"ticket":1}`), Subagent: "child", SubagentTurn: "native-turn"} + for range 2 { + if err = ops.RecordFunctionCall(ctx, tenant, session.ID, root, call); err != nil { + t.Fatal(err) + } + } + callID := items.Identity(childTurn, "tool:native-call") + changes, err := sessionAdapter(s).ListSessionEvents(ctx, tenant, session.ID, before) + if err != nil { + t.Fatal(err) + } + added := 0 + for _, change := range changes { + event := change.Event + if event.Type != "agent.session.turn.item.added" { + continue + } + added++ + item := event.Item + arguments, _ := json.Marshal(item.Arguments) + if event.TurnID != childTurn || item.Type != "function_call" || item.ID != callID || item.CallID != callID || item.TurnID != childTurn || item.Name != "lookup" || string(arguments) != `{"ticket":1}` || item.Status != "in_progress" { + t.Fatal("child function_call event", event) + } + } + if added != 1 { + t.Fatal("retry published again or nothing", added, changes) + } + assertFunctionState(t, s, tenant, session.ID, sessions.TurnWaiting, 1) + // The official SDK answers with the child Turn ID the event names. + if err = SubmitFixtureFunctionResult(ctx, s, tenant, session.ID, childTurn, callID, json.RawMessage(`{"success":true,"output":"ok"}`)); err != nil { + t.Fatal(err) + } + if err = ops.ConfirmFunctionResult(ctx, tenant, session.ID, root, callID); err != nil { + t.Fatal(err) + } + assertFunctionState(t, s, tenant, session.ID, sessions.TurnInProgress, 0) +}