diff --git a/sdk/go/agent/agent.go b/sdk/go/agent/agent.go index 872b64aa8..5e116c308 100644 --- a/sdk/go/agent/agent.go +++ b/sdk/go/agent/agent.go @@ -365,11 +365,13 @@ type Config struct { // plane does not expect heartbeats. LeaseRefreshInterval time.Duration - // CallTimeout bounds every outbound HTTP call this agent makes as a - // client - cross-agent Call()s, memory backend requests, etc. - // Optional. Default: 15s. A reasoning-model-backed reasoner chained - // behind Call() (search + a large max_tokens reasoning response) can - // easily exceed the old hardcoded 15s, so raise this for such workloads. + // CallTimeout bounds every INDIVIDUAL outbound HTTP request this agent + // makes as a client - memory backend requests, and for cross-agent + // Call()s the async submit plus each status poll. It does NOT bound a + // Call() end-to-end: Call submits to the async execute endpoint and + // polls until the child execution finishes, so a child reasoner may run + // arbitrarily longer than CallTimeout (bound the overall wait with the + // ctx passed to Call instead). Optional. Default: 15s. CallTimeout time.Duration // DisableLeaseLoop disables automatic periodic lease refreshes. @@ -1565,7 +1567,45 @@ func (a *Agent) postExecutionStatus(ctx context.Context, callbackURL string, pay return lastErr } +// Poll pacing for Call's async-submit + wait loop. Mirrors the Python SDK's +// _await_execution_sync (sdk/python/agentfield/client.py:970-1011) with the +// AsyncConfig defaults from sdk/python/agentfield/async_config.py: +// interval starts at max(initial_poll_interval, 0.25s), doubles per poll +// capped at max_poll_interval (4s), and each sleep is jittered by +// uniform(0.8, 1.2) clamped to [50ms, 4s] (client.py:1141-1143). +const ( + callInitialPollInterval = 250 * time.Millisecond + callMaxPollInterval = 4 * time.Second + callMinPollInterval = 50 * time.Millisecond +) + +// nextCallPollInterval applies the Python _next_poll_interval jitter: +// uniform(0.8, 1.2) * current, clamped to [callMinPollInterval, callMaxPollInterval]. +func nextCallPollInterval(current time.Duration) time.Duration { + jittered := time.Duration(float64(current) * (0.8 + 0.4*rand.Float64())) + if jittered < callMinPollInterval { + return callMinPollInterval + } + if jittered > callMaxPollInterval { + return callMaxPollInterval + } + return jittered +} + // Call invokes another reasoner via the AgentField control plane, preserving execution context. +// +// The call is submitted to the asynchronous execute endpoint +// (POST /api/v1/execute/async/{target}) and the result is awaited by polling +// GET /api/v1/executions/{execution_id} until the execution reaches a terminal +// status — mirroring the Python SDK's app.call +// (sdk/python/agentfield/client.py _submit_execution_sync/_await_execution_sync). +// +// Timeout semantics: Config.CallTimeout bounds each individual HTTP request +// (the submit and every poll), NOT the end-to-end call. A child reasoner may +// run arbitrarily long; the overall wait is bounded only by ctx. Cancelling +// ctx aborts the wait but does NOT cancel the child execution server-side — +// the child keeps running on its node and the control plane records its +// result (the Python SDK behaves the same way when the caller stops waiting). func (a *Agent) Call(ctx context.Context, target string, input map[string]any) (map[string]any, error) { if strings.TrimSpace(a.cfg.AgentFieldURL) == "" { return nil, errors.New("AgentFieldURL is required to call other reasoners") @@ -1591,16 +1631,26 @@ func (a *Agent) Call(ctx context.Context, target string, input map[string]any) ( "target": target, "reasoner_id": strings.TrimPrefix(target, a.cfg.NodeID+"."), }) - url := fmt.Sprintf("%s/api/v1/execute/%s", strings.TrimSuffix(a.cfg.AgentFieldURL, "/"), strings.TrimPrefix(target, "/")) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + + base := strings.TrimSuffix(a.cfg.AgentFieldURL, "/") + + executionID, submittedRunID, err := a.submitAsyncExecution(ctx, base, target, body, execCtx, runID) if err != nil { - a.logExecutionError(ctx, "call.outbound.failed", "failed to build cross-node call request", map[string]any{ - "target": target, - "error": err.Error(), - }) - return nil, fmt.Errorf("build request: %w", err) + return nil, err } - req.Header.Set("Content-Type", "application/json") + if submittedRunID != "" { + runID = submittedRunID + } + + return a.awaitExecutionResult(ctx, base, target, executionID, runID, execCtx) +} + +// applyCallHeaders sets the execution-context lineage headers shared by the +// async submit and every status poll. The control plane reads these via +// readExecutionHeaders (control-plane/internal/handlers/execute.go) through +// the same prepareExecution path for both the sync and async endpoints, so +// workflow DAG parentage is recorded identically. +func (a *Agent) applyCallHeaders(req *http.Request, execCtx ExecutionContext, runID string) { req.Header.Set("Accept", "application/json") req.Header.Set("X-Run-ID", runID) if execCtx.ExecutionID != "" { @@ -1628,6 +1678,30 @@ func (a *Agent) Call(ctx context.Context, target string, input map[string]any) ( if a.cfg.Token != "" { req.Header.Set("Authorization", "Bearer "+a.cfg.Token) } +} + +// submitAsyncExecution POSTs to /api/v1/execute/async/{target} and returns the +// accepted execution's identifiers. Mirrors Python _submit_execution_sync +// (client.py:866-905): any HTTP status >= 400 is an ExecuteError, and a +// response without an execution_id is rejected. +func (a *Agent) submitAsyncExecution( + ctx context.Context, + base, target string, + body []byte, + execCtx ExecutionContext, + runID string, +) (executionID, submittedRunID string, err error) { + url := fmt.Sprintf("%s/api/v1/execute/async/%s", base, strings.TrimPrefix(target, "/")) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + a.logExecutionError(ctx, "call.outbound.failed", "failed to build cross-node call request", map[string]any{ + "target": target, + "error": err.Error(), + }) + return "", "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + a.applyCallHeaders(req, execCtx, runID) // Sign request with DID auth headers if configured if a.client != nil { @@ -1640,16 +1714,16 @@ func (a *Agent) Call(ctx context.Context, target string, input map[string]any) ( "target": target, "error": err.Error(), }) - return nil, fmt.Errorf("perform execute call: %w", err) + return "", "", fmt.Errorf("perform execute call: %w", err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("read execute response: %w", err) + return "", "", fmt.Errorf("read execute response: %w", err) } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode >= 400 { // Try to parse structured error from control plane response. var errResp struct { Error string `json:"error"` @@ -1661,7 +1735,7 @@ func (a *Agent) Call(ctx context.Context, target string, input map[string]any) ( "status": resp.StatusCode, "error": errResp.Error, }) - return nil, &ExecuteError{ + return "", "", &ExecuteError{ StatusCode: resp.StatusCode, Message: errResp.Error, ErrorDetails: errResp.ErrorDetails, @@ -1671,57 +1745,192 @@ func (a *Agent) Call(ctx context.Context, target string, input map[string]any) ( "target": target, "status": resp.StatusCode, }) - return nil, &ExecuteError{ + return "", "", &ExecuteError{ StatusCode: resp.StatusCode, Message: fmt.Sprintf("execute failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))), } } - var execResp struct { - ExecutionID string `json:"execution_id"` - RunID string `json:"run_id"` - Status string `json:"status"` - Result map[string]any `json:"result"` - ErrorMessage *string `json:"error_message"` - ErrorDetails interface{} `json:"error_details"` + var submitResp struct { + ExecutionID string `json:"execution_id"` + RunID string `json:"run_id"` + Status string `json:"status"` } - if err := json.Unmarshal(bodyBytes, &execResp); err != nil { + if err := json.Unmarshal(bodyBytes, &submitResp); err != nil { a.logExecutionError(ctx, "call.outbound.failed", "failed to decode cross-node call response", map[string]any{ "target": target, "error": err.Error(), }) - return nil, fmt.Errorf("decode execute response: %w", err) + return "", "", fmt.Errorf("decode execute response: %w", err) } - - if execResp.ErrorMessage != nil && *execResp.ErrorMessage != "" { - a.logExecutionError(ctx, "call.outbound.failed", "cross-node call returned execution error", map[string]any{ + if submitResp.ExecutionID == "" { + a.logExecutionError(ctx, "call.outbound.failed", "async submission missing execution id", map[string]any{ "target": target, - "error": *execResp.ErrorMessage, }) - return nil, &ExecuteError{ - StatusCode: resp.StatusCode, - Message: *execResp.ErrorMessage, - ErrorDetails: execResp.ErrorDetails, - } + return "", "", errors.New("execution submission missing identifiers") } - if !strings.EqualFold(execResp.Status, "succeeded") { - a.logExecutionError(ctx, "call.outbound.failed", "cross-node call did not succeed", map[string]any{ - "target": target, - "status": execResp.Status, - }) - return nil, &ExecuteError{ - StatusCode: resp.StatusCode, - Message: fmt.Sprintf("execute status %s", execResp.Status), - ErrorDetails: execResp.ErrorDetails, + + return submitResp.ExecutionID, submitResp.RunID, nil +} + +// awaitExecutionResult polls GET /api/v1/executions/{execution_id} until the +// execution reaches a terminal status. Mirrors Python _await_execution_sync +// (client.py:970-1011): statuses are normalized, "succeeded" returns the +// result, {failed, cancelled, timeout} surface the execution error (with the +// status endpoint's "error" field coalesced into the error message), and any +// other status keeps polling with jittered exponential backoff. The overall +// wait is unbounded — only ctx cancellation stops it — while each poll request +// is individually bounded by the HTTP client's CallTimeout. +func (a *Agent) awaitExecutionResult( + ctx context.Context, + base, target, executionID, runID string, + execCtx ExecutionContext, +) (map[string]any, error) { + pollURL := fmt.Sprintf("%s/api/v1/executions/%s", base, url.PathEscape(executionID)) + interval := callInitialPollInterval + + for { + if err := ctx.Err(); err != nil { + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call wait cancelled", map[string]any{ + "target": target, + "execution_id": executionID, + "error": err.Error(), + }) + return nil, err } - } - a.logExecutionInfo(ctx, "call.outbound.complete", "cross-node call completed", map[string]any{ - "target": target, - "execution_id": execResp.ExecutionID, - "run_id": execResp.RunID, - }) - return execResp.Result, nil + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + if err != nil { + return nil, fmt.Errorf("build status request: %w", err) + } + a.applyCallHeaders(req, execCtx, runID) + if a.client != nil { + a.client.SignHTTPRequest(req, nil) + } + + resp, err := a.httpClient.Do(req) + if err != nil { + // Distinguish caller cancellation from transport failures so the + // ctx-cancel contract surfaces context.Canceled/DeadlineExceeded. + if ctxErr := ctx.Err(); ctxErr != nil { + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call wait cancelled", map[string]any{ + "target": target, + "execution_id": executionID, + "error": ctxErr.Error(), + }) + return nil, ctxErr + } + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call status poll failed", map[string]any{ + "target": target, + "execution_id": executionID, + "error": err.Error(), + }) + return nil, fmt.Errorf("poll execution status: %w", err) + } + + bodyBytes, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("read execution status: %w", readErr) + } + + // Python's poll uses raise_for_status(): any HTTP error aborts the wait. + if resp.StatusCode >= 400 { + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call status poll returned error status", map[string]any{ + "target": target, + "execution_id": executionID, + "status": resp.StatusCode, + }) + return nil, &ExecuteError{ + StatusCode: resp.StatusCode, + Message: fmt.Sprintf("execution status failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))), + } + } + + var statusResp struct { + ExecutionID string `json:"execution_id"` + RunID string `json:"run_id"` + Status string `json:"status"` + Result json.RawMessage `json:"result"` + Error *string `json:"error"` + ErrorMessage *string `json:"error_message"` + ErrorDetails interface{} `json:"error_details"` + } + if err := json.Unmarshal(bodyBytes, &statusResp); err != nil { + a.logExecutionError(ctx, "call.outbound.failed", "failed to decode execution status response", map[string]any{ + "target": target, + "execution_id": executionID, + "error": err.Error(), + }) + return nil, fmt.Errorf("decode execute response: %w", err) + } + + status := types.NormalizeStatus(statusResp.Status) + + if status == types.ExecutionStatusSucceeded { + var result map[string]any + if len(statusResp.Result) > 0 && string(statusResp.Result) != "null" { + if err := json.Unmarshal(statusResp.Result, &result); err != nil { + a.logExecutionError(ctx, "call.outbound.failed", "failed to decode cross-node call result", map[string]any{ + "target": target, + "execution_id": executionID, + "error": err.Error(), + }) + return nil, fmt.Errorf("decode execute response: %w", err) + } + } + a.logExecutionInfo(ctx, "call.outbound.complete", "cross-node call completed", map[string]any{ + "target": target, + "execution_id": executionID, + "run_id": runID, + }) + return result, nil + } + + if types.TerminalStatuses[status] { + // error_message, falling back to the status endpoint's "error" + // field — Python parity (client.py:1000-1002). + message := "" + if statusResp.ErrorMessage != nil && *statusResp.ErrorMessage != "" { + message = *statusResp.ErrorMessage + } else if statusResp.Error != nil && *statusResp.Error != "" { + message = *statusResp.Error + } + if message == "" { + message = fmt.Sprintf("execute status %s", status) + } + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call did not succeed", map[string]any{ + "target": target, + "execution_id": executionID, + "status": status, + "error": message, + }) + // StatusBadGateway matches what the synchronous execute endpoint + // returns for failed executions, so ExecuteError.StatusCode keeps + // its pre-async meaning for callers that inspect it. + return nil, &ExecuteError{ + StatusCode: http.StatusBadGateway, + Message: message, + ErrorDetails: statusResp.ErrorDetails, + } + } + + // Non-terminal: sleep with jittered exponential backoff, honoring ctx. + select { + case <-ctx.Done(): + a.logExecutionError(ctx, "call.outbound.failed", "cross-node call wait cancelled", map[string]any{ + "target": target, + "execution_id": executionID, + "error": ctx.Err().Error(), + }) + return nil, ctx.Err() + case <-time.After(nextCallPollInterval(interval)): + } + interval *= 2 + if interval > callMaxPollInterval { + interval = callMaxPollInterval + } + } } // emitWorkflowEvent sends a workflow event to the control plane asynchronously. diff --git a/sdk/go/agent/agent_call_coverage_test.go b/sdk/go/agent/agent_call_coverage_test.go new file mode 100644 index 000000000..7065d31ef --- /dev/null +++ b/sdk/go/agent/agent_call_coverage_test.go @@ -0,0 +1,359 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests close the async-submit + poll error/edge branches of Agent.Call +// that the happy-path tests in agent_test.go do not reach. Each is derived from +// the behavior contract of the async Call rewrite (submit -> poll -> terminal +// mapping, error propagation, ctx handling), not from mirroring the code. + +// callErrBody is a response body whose Read always fails, used to exercise the +// io.ReadAll error branches on both the submit response and each status poll. +type callErrBody struct{} + +func (callErrBody) Read([]byte) (int, error) { return 0, errors.New("simulated body read failure") } +func (callErrBody) Close() error { return nil } + +// newCallTestAgent builds a minimal Agent for exercising Call's helpers. The +// AgentFieldURL is a harmless placeholder — the submit/poll base URL is passed +// explicitly to the helper under test, so no real network is used unless a +// test wires a fake transport. +func newCallTestAgent(t *testing.T, agentFieldURL string) *Agent { + t.Helper() + a, err := New(Config{ + NodeID: "node-1", + Version: "1.0.0", + AgentFieldURL: agentFieldURL, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + return a +} + +// --- nextCallPollInterval clamps ------------------------------------------- + +// Contract: the jittered poll interval is always clamped into +// [callMinPollInterval, callMaxPollInterval]. A tiny input clamps up to the +// floor; a huge input clamps down to the ceiling; an in-range input is returned +// jittered without clamping. +func TestNextCallPollInterval_Clamps(t *testing.T) { + // Jitter is uniform(0.8, 1.2)*current, so these bounds hold for every draw. + for i := 0; i < 64; i++ { + // 1ms * 1.2 = 1.2ms, always below the 50ms floor -> clamps up. + assert.Equal(t, callMinPollInterval, nextCallPollInterval(1*time.Millisecond), + "sub-floor interval must clamp up to callMinPollInterval") + + // 10s * 0.8 = 8s, always above the 4s ceiling -> clamps down. + assert.Equal(t, callMaxPollInterval, nextCallPollInterval(10*time.Second), + "super-ceiling interval must clamp down to callMaxPollInterval") + + // 1s jittered stays within [0.8s, 1.2s] -> no clamp, value returned as-is. + mid := nextCallPollInterval(1 * time.Second) + assert.GreaterOrEqual(t, mid, 800*time.Millisecond) + assert.LessOrEqual(t, mid, 1200*time.Millisecond) + } +} + +// --- submitAsyncExecution error branches ----------------------------------- + +// Contract: an unbuildable submit request surfaces a "build request" error +// before any network I/O. +func TestSubmitAsyncExecution_BuildRequestError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + + _, _, err := a.submitAsyncExecution( + context.Background(), "://bad", "target.node", + []byte(`{"input":{}}`), ExecutionContext{}, "run-1", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "build request") +} + +// Contract: a transport failure on the async submit surfaces a +// "perform execute call" error. +func TestSubmitAsyncExecution_TransportError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + a.httpClient = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })} + + _, _, err := a.submitAsyncExecution( + context.Background(), "http://cp.example", "target.node", + []byte(`{"input":{}}`), ExecutionContext{}, "run-1", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "perform execute call") +} + +// Contract: a submit response whose body cannot be read surfaces a +// "read execute response" error. +func TestSubmitAsyncExecution_ReadBodyError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + a.httpClient = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusAccepted, + Header: make(http.Header), + Body: callErrBody{}, + }, nil + })} + + _, _, err := a.submitAsyncExecution( + context.Background(), "http://cp.example", "target.node", + []byte(`{"input":{}}`), ExecutionContext{}, "run-1", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "read execute response") +} + +// Contract: when the control plane rejects the submit with a structured JSON +// error body, Call returns an *ExecuteError carrying the HTTP status and the +// control plane's "error" message (and error_details). +func TestCall_SubmitStructuredError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "permission denied", + "error_details": map[string]any{"code": "forbidden"}, + }) + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + result, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.Error(t, err) + assert.Nil(t, result) + + var execErr *ExecuteError + require.ErrorAs(t, err, &execErr) + assert.Equal(t, http.StatusForbidden, execErr.StatusCode) + assert.Contains(t, execErr.Message, "permission denied") + assert.NotNil(t, execErr.ErrorDetails) +} + +// Contract: a 2xx submit whose body is not valid JSON surfaces a +// "decode execute response" error. +func TestCall_SubmitDecodeError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("this is definitely not json")) + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + _, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode execute response") +} + +// --- awaitExecutionResult error branches ----------------------------------- + +// Contract: a context already cancelled before the wait loop starts aborts +// immediately with the context error, without polling. +func TestAwaitExecutionResult_ContextAlreadyCancelled(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := a.awaitExecutionResult( + ctx, "http://cp.example", "target.node", "exec-1", "run-1", ExecutionContext{}, + ) + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorIs(t, err, context.Canceled) +} + +// Contract: an unbuildable status-poll request surfaces a "build status +// request" error. +func TestAwaitExecutionResult_BuildRequestError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + + result, err := a.awaitExecutionResult( + context.Background(), "://bad", "target.node", "exec-1", "run-1", ExecutionContext{}, + ) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "build status request") +} + +// Contract: when the caller's context is cancelled while a poll request is in +// flight, the wait surfaces the context error (not a generic transport error), +// preserving the ctx-cancel contract. +func TestAwaitExecutionResult_ContextCancelDuringPoll(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + a.httpClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + })} + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + result, err := a.awaitExecutionResult( + ctx, "http://cp.example", "target.node", "exec-1", "run-1", ExecutionContext{}, + ) + require.Error(t, err) + assert.Nil(t, result) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +// Contract: a transport failure on a status poll (with a live context) +// surfaces a "poll execution status" error. +func TestAwaitExecutionResult_TransportError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + a.httpClient = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection reset by peer") + })} + + result, err := a.awaitExecutionResult( + context.Background(), "http://cp.example", "target.node", "exec-1", "run-1", ExecutionContext{}, + ) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "poll execution status") +} + +// Contract: a status-poll response whose body cannot be read surfaces a +// "read execution status" error. +func TestAwaitExecutionResult_ReadBodyError(t *testing.T) { + a := newCallTestAgent(t, "http://placeholder.invalid") + a.httpClient = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: callErrBody{}, + }, nil + })} + + result, err := a.awaitExecutionResult( + context.Background(), "http://cp.example", "target.node", "exec-1", "run-1", ExecutionContext{}, + ) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "read execution status") +} + +// Contract: an HTTP error status on a status poll aborts the wait with an +// *ExecuteError carrying that status (Python raise_for_status parity). +func TestCall_PollReturnsErrorStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "queued", + }) + default: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("upstream boom")) + } + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + result, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.Error(t, err) + assert.Nil(t, result) + + var execErr *ExecuteError + require.ErrorAs(t, err, &execErr) + assert.Equal(t, http.StatusInternalServerError, execErr.StatusCode) + assert.Contains(t, execErr.Message, "execution status failed") +} + +// Contract: a status-poll body that is not valid JSON surfaces a +// "decode execute response" error. +func TestCall_PollDecodeError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "queued", + }) + default: + _, _ = w.Write([]byte("<>")) + } + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + _, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode execute response") +} + +// Contract: a succeeded execution whose "result" field is not a JSON object +// (so it cannot decode into the result map) surfaces a "decode execute +// response" error rather than a silent empty result. +func TestCall_SucceededResultDecodeError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "queued", + }) + default: + // result is a JSON string, not an object -> unmarshal into + // map[string]any fails. + _, _ = w.Write([]byte(`{"status":"succeeded","result":"not-an-object"}`)) + } + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + _, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode execute response") +} + +// Contract: a succeeded execution with a null result yields a nil result map +// and no error (the len/"null" guard skips decoding). +func TestCall_SucceededNullResult(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "queued", + }) + default: + _, _ = w.Write([]byte(`{"status":"succeeded","result":null}`)) + } + })) + defer server.Close() + + a := newCallTestAgent(t, server.URL) + + result, err := a.Call(context.Background(), "target.node", map[string]any{}) + require.NoError(t, err) + assert.Nil(t, result) +} diff --git a/sdk/go/agent/agent_test.go b/sdk/go/agent/agent_test.go index 160258040..403c88b08 100644 --- a/sdk/go/agent/agent_test.go +++ b/sdk/go/agent/agent_test.go @@ -697,27 +697,61 @@ func TestHandleReasoner_Error(t *testing.T) { assert.Contains(t, result["error"], "assert.AnError") } +// TestCall pins the async-submit + wait contract. Contract: Call POSTs to the +// ASYNC execute endpoint (/api/v1/execute/async/{target}) with the full set of +// lineage headers (the control plane reads them identically for sync and async +// via readExecutionHeaders, so DAG parentage is preserved), then polls +// GET /api/v1/executions/{id} — with the same lineage headers — until the +// execution succeeds, and returns the child's result map. func TestCall(t *testing.T) { + var polls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/execute/") { - // Verify headers + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/execute/async/target.node": + // Verify lineage headers on the submit assert.Equal(t, "run-1", r.Header.Get("X-Run-ID")) assert.Equal(t, "parent-exec", r.Header.Get("X-Parent-Execution-ID")) assert.Equal(t, "session-1", r.Header.Get("X-Session-ID")) assert.Equal(t, "actor-1", r.Header.Get("X-Actor-ID")) + assert.Equal(t, "node-1", r.Header.Get("X-Caller-Agent-ID")) var reqBody map[string]any json.NewDecoder(r.Body).Decode(&reqBody) assert.Equal(t, map[string]any{"value": float64(42)}, reqBody["input"]) - resp := map[string]any{ + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "queued", + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/executions/exec-1": + // Lineage headers ride along on polls too (Python parity: the + // wait loop reuses the submit headers). + assert.Equal(t, "run-1", r.Header.Get("X-Run-ID")) + assert.Equal(t, "parent-exec", r.Header.Get("X-Parent-Execution-ID")) + + // First poll: still running; second poll: terminal result. + if polls.Add(1) == 1 { + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-1", + "run_id": "run-1", + "status": "running", + }) + return + } + json.NewEncoder(w).Encode(map[string]any{ "execution_id": "exec-1", "run_id": "run-1", "status": "succeeded", "result": map[string]any{"output": "result"}, - } + }) + case strings.HasSuffix(r.URL.Path, "/logs"): + // Structured execution-log shipping — irrelevant to this contract. w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(resp) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) } })) defer server.Close() @@ -744,33 +778,182 @@ func TestCall(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, "result", result["output"]) + assert.GreaterOrEqual(t, polls.Load(), int32(2), "expected at least two status polls") +} + +// TestCall_OutlivesCallTimeout is the regression test for the 15s +// 'context deadline exceeded' bug: a child reasoner that runs LONGER than +// Config.CallTimeout must still complete successfully, because CallTimeout +// bounds each HTTP request (submit + each poll), not the end-to-end call. +func TestCall_OutlivesCallTimeout(t *testing.T) { + const callTimeout = 300 * time.Millisecond + start := time.Now() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/execute/async/"): + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-slow", + "run_id": "run-slow", + "status": "queued", + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/executions/exec-slow": + // Child stays 'running' for 3x CallTimeout, then succeeds. Every + // individual poll response is fast — only the CHILD is slow. + if time.Since(start) < 3*callTimeout { + json.NewEncoder(w).Encode(map[string]any{"status": "running"}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "status": "succeeded", + "result": map[string]any{"took": "longer than CallTimeout"}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + agent, err := New(Config{ + NodeID: "node-1", + Version: "1.0.0", + AgentFieldURL: server.URL, + CallTimeout: callTimeout, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + + result, err := agent.Call(context.Background(), "target.slow", map[string]any{}) + require.NoError(t, err, "a child running longer than CallTimeout must not kill the call") + require.NotNil(t, result) + assert.Equal(t, "longer than CallTimeout", result["took"]) + assert.GreaterOrEqual(t, time.Since(start), 3*callTimeout, + "call must actually have waited past CallTimeout") +} + +// TestCall_CtxCancelAbortsWait: cancelling the caller's ctx aborts the wait +// loop promptly (returning the context error). Note the contract: the child +// execution is NOT cancelled server-side — matching the Python SDK. +func TestCall_CtxCancelAbortsWait(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/execute/async/"): + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-hang", + "run_id": "run-hang", + "status": "queued", + }) + case r.Method == http.MethodGet: + // Child never finishes. + json.NewEncoder(w).Encode(map[string]any{"status": "running"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + agent, err := New(Config{ + NodeID: "node-1", + Version: "1.0.0", + AgentFieldURL: server.URL, + Logger: log.New(io.Discard, "", 0), + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(250 * time.Millisecond) + cancel() + }() + + start := time.Now() + result, err := agent.Call(ctx, "target.hang", map[string]any{}) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Nil(t, result) + assert.Less(t, time.Since(start), 5*time.Second, "cancel must abort the wait promptly") } func TestCall_ErrorHandling(t *testing.T) { tests := []struct { name string serverResponse func(w http.ResponseWriter, r *http.Request) - wantErr bool + wantErrSubstr string }{ { - name: "API error", + name: "API error on submit", serverResponse: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("bad request")) }, - wantErr: true, + wantErrSubstr: "bad request", + }, + { + name: "submission missing execution id", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{"status": "queued"}) + }, + wantErrSubstr: "missing identifiers", }, { - name: "execution failed status", + name: "execution failed status via poll", serverResponse: func(w http.ResponseWriter, r *http.Request) { - resp := map[string]any{ + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-f", + "run_id": "run-f", + "status": "queued", + }) + return + } + json.NewEncoder(w).Encode(map[string]any{ "status": "failed", "error_message": "execution failed", + }) + }, + wantErrSubstr: "execution failed", + }, + { + name: "failed execution coalesces error field into message", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-e", + "run_id": "run-e", + "status": "queued", + }) + return } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(resp) + // The status endpoint reports failures in "error" + // (ExecutionStatusResponse), not "error_message" — Python + // coalesces it (client.py:1000-1002) and so must we. + json.NewEncoder(w).Encode(map[string]any{ + "status": "failed", + "error": "boom from error field", + }) }, - wantErr: true, + wantErrSubstr: "boom from error field", + }, + { + name: "cancelled execution is terminal", + serverResponse: func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-c", + "run_id": "run-c", + "status": "queued", + }) + return + } + json.NewEncoder(w).Encode(map[string]any{"status": "cancelled"}) + }, + wantErrSubstr: "execute status cancelled", }, } @@ -790,13 +973,9 @@ func TestCall_ErrorHandling(t *testing.T) { require.NoError(t, err) result, err := agent.Call(context.Background(), "target", map[string]any{}) - if tt.wantErr { - assert.Error(t, err) - assert.Nil(t, result) - } else { - assert.NoError(t, err) - assert.NotNil(t, result) - } + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), tt.wantErrSubstr) }) } } @@ -994,8 +1173,11 @@ func TestAIWithTools(t *testing.T) { switch r.URL.Path { case "/api/v1/discovery/capabilities": _, _ = w.Write([]byte(`{"discovered_at":"2025-01-01T00:00:00Z","total_agents":1,"total_reasoners":1,"total_skills":0,"pagination":{"limit":50,"offset":0,"has_more":false},"capabilities":[{"agent_id":"agent-1","reasoners":[{"id":"lookup","invocation_target":"agent-1.lookup","input_schema":{"type":"object"}}],"skills":[]}]}`)) - case "/api/v1/execute/agent-1.lookup": - _, _ = w.Write([]byte(`{"status":"open"}`)) + case "/api/v1/execute/async/agent-1.lookup": + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"execution_id":"exec-tool-1","run_id":"run-tool-1","status":"queued"}`)) + case "/api/v1/executions/exec-tool-1": + _, _ = w.Write([]byte(`{"execution_id":"exec-tool-1","run_id":"run-tool-1","status":"succeeded","result":{"status":"open"}}`)) case "/chat/completions": count := chatRequests.Add(1) if count == 1 { @@ -1037,9 +1219,10 @@ func TestAIWithTools(t *testing.T) { return } _ = json.NewEncoder(w).Encode(ai.Response{Choices: []ai.Choice{{Message: ai.Message{Content: []ai.ContentPart{{Type: "text", Text: "blocked"}}}}}}) - case "/api/v1/execute/agent-1.lookup": + case "/api/v1/execute/async/agent-1.lookup": executeCalls.Add(1) - _, _ = w.Write([]byte(`{"status":"open"}`)) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"execution_id":"exec-tool-2","run_id":"run-tool-2","status":"queued"}`)) default: t.Fatalf("unexpected path %s", r.URL.Path) } @@ -1683,10 +1866,19 @@ func TestCallLocalUnknownReasoner(t *testing.T) { } func TestCall_TargetPrefixing(t *testing.T) { - var capturedPath string + var capturedSubmitPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path + if r.Method == http.MethodPost { + capturedSubmitPath = r.URL.Path + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "execution_id": "exec-p", + "run_id": "run-p", + "status": "queued", + }) + return + } resp := map[string]any{ "status": "succeeded", @@ -1706,5 +1898,5 @@ func TestCall_TargetPrefixing(t *testing.T) { _, err := agent.Call(context.Background(), "lookup", nil) require.NoError(t, err) - assert.Contains(t, capturedPath, "/execute/node-1.lookup") + assert.Contains(t, capturedSubmitPath, "/execute/async/node-1.lookup") } diff --git a/sdk/go/agent/note.go b/sdk/go/agent/note.go index 2fa8801fd..8a41c8805 100644 --- a/sdk/go/agent/note.go +++ b/sdk/go/agent/note.go @@ -61,8 +61,11 @@ func (a *Agent) sendNote(ctx context.Context, message string, tags []string) { // Get execution context from the provided context execCtx := ExecutionContextFrom(ctx) - // Build note URL (canonical endpoint: /api/v1/executions/note) - noteURL := strings.TrimSuffix(baseURL, "/") + "/executions/note" + // Build note URL. AgentFieldURL is the bare control-plane base (e.g. + // http://localhost:8080); the canonical endpoint is /api/v1/executions/note, + // consistent with the other endpoint builders in agent.go and the client + // package. Omitting /api/v1 posts to an unregistered route and 404s. + noteURL := strings.TrimSuffix(baseURL, "/") + "/api/v1/executions/note" // Build payload payload := notePayload{ diff --git a/sdk/go/agent/note_test.go b/sdk/go/agent/note_test.go index c830bf0cc..059684d19 100644 --- a/sdk/go/agent/note_test.go +++ b/sdk/go/agent/note_test.go @@ -39,7 +39,7 @@ func TestNote_Basic(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", // Will be used directly + AgentFieldURL: server.URL, // bare control-plane base; /api/v1 appended by note.go Logger: log.New(io.Discard, "", 0), } @@ -97,7 +97,7 @@ func TestNotef_Formatted(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", + AgentFieldURL: server.URL, Logger: log.New(io.Discard, "", 0), } @@ -134,7 +134,7 @@ func TestNote_NoTags(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", + AgentFieldURL: server.URL, Logger: log.New(io.Discard, "", 0), } @@ -191,7 +191,7 @@ func TestNote_ServerError(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", + AgentFieldURL: server.URL, Logger: log.New(io.Discard, "", 0), } @@ -209,7 +209,14 @@ func TestNote_ServerError(t *testing.T) { time.Sleep(200 * time.Millisecond) } -func TestNote_URLConversion(t *testing.T) { +// TestNote_URLPath pins the canonical note endpoint. Contract: given the bare +// control-plane base URL as AgentFieldURL (the same value handed to +// client.New, which every other endpoint appends /api/v1/... to), a note POSTs +// to /api/v1/executions/note — the route the control plane actually registers +// (control-plane/internal/server/routes_core.go). A trailing slash on the base +// must not change the path. The previous test asserted the pre-fix path +// (/executions/note), which 404s against a real control plane. +func TestNote_URLPath(t *testing.T) { var requestPath string requestReceived := make(chan struct{}) @@ -221,19 +228,19 @@ func TestNote_URLConversion(t *testing.T) { defer server.Close() tests := []struct { - name string - agentFieldURL string - expectedPath string + name string + agentFieldURL string + expectedPath string }{ { - name: "Standard /api/v1 URL", - agentFieldURL: server.URL + "/api/v1", + name: "bare base URL", + agentFieldURL: server.URL, expectedPath: "/api/v1/executions/note", }, { - name: "URL without /api/v1", - agentFieldURL: server.URL, - expectedPath: "/executions/note", + name: "bare base URL with trailing slash", + agentFieldURL: server.URL + "/", + expectedPath: "/api/v1/executions/note", }, } @@ -282,7 +289,7 @@ func TestNote_WithToken(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", + AgentFieldURL: server.URL, Token: "test-token-123", Logger: log.New(io.Discard, "", 0), } @@ -316,7 +323,7 @@ func TestNote_FireAndForget(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: slowServer.URL + "/api/v1", + AgentFieldURL: slowServer.URL, Logger: log.New(io.Discard, "", 0), } @@ -350,7 +357,7 @@ func TestNote_MultipleNotes(t *testing.T) { cfg := Config{ NodeID: "test-node", Version: "1.0.0", - AgentFieldURL: server.URL + "/api/v1", + AgentFieldURL: server.URL, Logger: log.New(io.Discard, "", 0), } diff --git a/sdk/go/harness/claudecode.go b/sdk/go/harness/claudecode.go index debd5f96b..c15fda638 100644 --- a/sdk/go/harness/claudecode.go +++ b/sdk/go/harness/claudecode.go @@ -12,6 +12,10 @@ import ( // It uses the `claude` CLI with `--print` mode for non-interactive output. type ClaudeCodeProvider struct { BinPath string + + // runCLI is the subprocess runner, injectable for tests. It defaults to + // RunCLIWithStdin so the prompt can be delivered via stdin. + runCLI func(ctx context.Context, cmd []string, env map[string]string, cwd string, timeout int, stdin []byte) (*CLIResult, error) } // NewClaudeCodeProvider creates a Claude Code provider. If binPath is empty, @@ -20,7 +24,7 @@ func NewClaudeCodeProvider(binPath string) *ClaudeCodeProvider { if binPath == "" { binPath = "claude" } - return &ClaudeCodeProvider{BinPath: binPath} + return &ClaudeCodeProvider{BinPath: binPath, runCLI: RunCLIWithStdin} } // permissionMap translates common permission mode names to Claude Code flags. @@ -30,7 +34,17 @@ var permissionMap = map[string]string{ } func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options Options) (*RawResult, error) { - cmd := []string{p.BinPath, "--print", "--output-format", "json"} + // stream-json (rather than plain json) keeps RunCLI's idle watchdog fed: + // with --output-format json the CLI is COMPLETELY SILENT until the run + // finishes, so any turn quieter than AGENTFIELD_HARNESS_IDLE_SECONDS + // (default 120s) was SIGKILLed mid-run. stream-json emits per-message + // JSONL events (init, thinking deltas, assistant messages, final result) + // as they happen — verified against claude CLI 2.1.191 — and the final + // "result" event carries the same fields as the json format (result, + // session_id, num_turns, total_cost_usd), so parseJSONOutput's contract + // is unchanged. In --print mode the CLI hard-requires --verbose alongside + // stream-json ("--output-format=stream-json requires --verbose"). + cmd := []string{p.BinPath, "--print", "--output-format", "stream-json", "--verbose"} if options.Model != "" { cmd = append(cmd, "--model", options.Model) @@ -64,8 +78,12 @@ func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options cmd = append(cmd, "--allowedTools", tool) } - // The prompt is passed via stdin-like argument (last positional arg) - cmd = append(cmd, prompt) + // The prompt is delivered on stdin, NOT as a trailing positional argument. + // `--allowedTools` is variadic in the claude CLI (verified against 2.1.191): + // a positional prompt immediately following it is greedily absorbed into the + // tool list, so `--print` sees no prompt and exits non-zero with + // "Input must be provided ... when using --print". `claude --print` reads + // the prompt from stdin when no positional is present. env := make(map[string]string) for k, v := range options.Env { @@ -81,9 +99,14 @@ func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options cwd = options.ProjectDir } + runCLI := p.runCLI + if runCLI == nil { + runCLI = RunCLIWithStdin + } + startAPI := time.Now() - cliResult, err := RunCLI(ctx, cmd, env, cwd, options.timeout()) + cliResult, err := runCLI(ctx, cmd, env, cwd, options.timeout(), []byte(prompt)) apiMS := int(time.Since(startAPI).Milliseconds()) if err != nil { @@ -109,7 +132,7 @@ func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options return nil, err } - // Parse JSON output from Claude Code's --output-format json + // Parse the JSONL event stream from Claude Code's --output-format stream-json raw := &RawResult{ Metrics: Metrics{ DurationAPIMS: apiMS, @@ -144,9 +167,16 @@ func (p *ClaudeCodeProvider) Execute(ctx context.Context, prompt string, options return raw, nil } -// parseJSONOutput attempts to extract structured data from Claude Code's JSON output. +// parseJSONOutput extracts structured data from Claude Code's JSONL output. +// It consumes both the stream-json event stream (system/init, thinking +// deltas, assistant messages, terminal "result" event) and the legacy +// single-line json format: every line is parsed as an event, all events are +// collected into Messages (mirroring the Python provider, which appends every +// SDK stream message), and the LAST "result" event supplies Result, +// SessionID, CostUSD and NumTurns — its fields are identical across both +// output formats. Assistant-message text is only a fallback when no result +// event ever arrives (e.g. a stream truncated by a crash). func (p *ClaudeCodeProvider) parseJSONOutput(stdout string, raw *RawResult) { - // Claude Code with --output-format json outputs JSONL var messages []map[string]any var resultText string var sessionID string diff --git a/sdk/go/harness/claudecode_defaultrunner_test.go b/sdk/go/harness/claudecode_defaultrunner_test.go new file mode 100644 index 000000000..3bbcc242b --- /dev/null +++ b/sdk/go/harness/claudecode_defaultrunner_test.go @@ -0,0 +1,38 @@ +package harness + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClaudeCodeProvider_NilRunCLIUsesDefaultRunner covers the runCLI == nil +// fallback in Execute. NewClaudeCodeProvider always wires runCLI to +// RunCLIWithStdin, so a provider built via the constructor never exercises the +// fallback. A provider constructed as a struct literal (e.g. zero-valued +// runCLI) must still run: Execute defaults runCLI to RunCLIWithStdin and drives +// the real subprocess. +// +// Contract: with runCLI unset, Execute delivers the prompt to the default +// runner, runs the binary, and parses its stream-json result. +func TestClaudeCodeProvider_NilRunCLIUsesDefaultRunner(t *testing.T) { + dir := t.TempDir() + script := writeTestScript(t, dir, "claude", + `#!/bin/sh +echo '{"type":"result","result":"defaulted","session_id":"s-default","num_turns":1}' +`) + + // Struct literal (not NewClaudeCodeProvider) leaves runCLI nil, forcing the + // default-runner path in Execute. + p := &ClaudeCodeProvider{BinPath: script} + require.Nil(t, p.runCLI, "precondition: runCLI must be nil to exercise the fallback") + + raw, err := p.Execute(context.Background(), "hello", Options{}) + require.NoError(t, err) + require.False(t, raw.IsError, "unexpected error: %s", raw.ErrorMessage) + assert.Equal(t, "defaulted", raw.Result) + assert.Equal(t, "s-default", raw.Metrics.SessionID) + assert.Equal(t, 1, raw.Metrics.NumTurns) +} diff --git a/sdk/go/harness/claudecode_integration_test.go b/sdk/go/harness/claudecode_integration_test.go new file mode 100644 index 000000000..04de4e832 --- /dev/null +++ b/sdk/go/harness/claudecode_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration + +package harness + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClaudeCodeProvider_Integration_StdinWithTools drives the REAL claude CLI +// with an allowed-tools list set, which places the variadic --allowedTools flag +// last in the arg vector. Before the stdin fix the trailing positional prompt +// was swallowed by --allowedTools and the CLI exited non-zero with +// "Input must be provided ... when using --print". Delivering the prompt on +// stdin (no trailing positional) resolves it. +// +// It also proves the stream-json output path end-to-end: the provider invokes +// the CLI with `--output-format stream-json --verbose` (so the idle watchdog +// sees per-message progress instead of total silence), and the assertions on +// SessionID/CostUSD/NumTurns only pass if the terminal "result" event of the +// real stream was parsed. +// +// Requires an authenticated claude CLI. Its path is taken from +// AGENTFIELD_CLAUDE_BIN (the package TestMain shadows a bare "claude" on PATH +// with a stub, so an explicit path is required to reach the real binary). Opt-in: +// +// AGENTFIELD_CLAUDE_BIN="$(command -v claude)" \ +// go test -tags integration -run TestClaudeCodeProvider_Integration ./harness/ +func TestClaudeCodeProvider_Integration_StdinWithTools(t *testing.T) { + binPath := os.Getenv("AGENTFIELD_CLAUDE_BIN") + if binPath == "" { + t.Skip("set AGENTFIELD_CLAUDE_BIN to the real claude binary to run this test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + p := NewClaudeCodeProvider(binPath) + raw, err := p.Execute(ctx, "Reply with exactly: HELLO_AGENTFIELD", Options{ + Model: "haiku", + Tools: []string{"Read", "Write"}, + Timeout: 120, + }) + require.NoError(t, err) + + t.Logf("IsError: %v", raw.IsError) + t.Logf("ErrorMessage: %s", raw.ErrorMessage) + t.Logf("Result: %s", raw.Result) + t.Logf("ReturnCode: %d", raw.ReturnCode) + t.Logf("SessionID: %s", raw.Metrics.SessionID) + t.Logf("NumTurns: %d", raw.Metrics.NumTurns) + if raw.Metrics.CostUSD != nil { + t.Logf("CostUSD: %f", *raw.Metrics.CostUSD) + } + + assert.False(t, raw.IsError, "expected no error, got: %s", raw.ErrorMessage) + assert.Contains(t, raw.Result, "HELLO_AGENTFIELD") + + // Stream-json parsing proof: these fields only exist on the terminal + // "result" event of the JSONL stream. + assert.NotEmpty(t, raw.Metrics.SessionID, "session_id must be parsed from the stream's result event") + require.NotNil(t, raw.Metrics.CostUSD, "total_cost_usd must be parsed from the stream's result event") + assert.Greater(t, *raw.Metrics.CostUSD, 0.0) + assert.GreaterOrEqual(t, raw.Metrics.NumTurns, 1) + assert.Greater(t, len(raw.Messages), 1, "stream-json must yield multiple events, not one json blob") +} diff --git a/sdk/go/harness/claudecode_test.go b/sdk/go/harness/claudecode_test.go new file mode 100644 index 000000000..dad45b271 --- /dev/null +++ b/sdk/go/harness/claudecode_test.go @@ -0,0 +1,147 @@ +package harness + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClaudeCodeProvider_PromptDeliveredViaStdin pins the fix for the variadic +// --allowedTools bug in the claude CLI (verified against 2.1.191). +// +// Contract: +// - The prompt must be handed to the subprocess on stdin. +// - The prompt must NOT appear as an argument, in particular not as a trailing +// positional after --allowedTools — claude's --allowedTools is variadic and +// greedily absorbs a following positional, leaving `--print` with no prompt +// and a non-zero exit ("Input must be provided ... when using --print"). +func TestClaudeCodeProvider_PromptDeliveredViaStdin(t *testing.T) { + p := NewClaudeCodeProvider("claude") + + var gotCmd []string + var gotStdin []byte + p.runCLI = func(_ context.Context, cmd []string, _ map[string]string, _ string, _ int, stdin []byte) (*CLIResult, error) { + gotCmd = append([]string(nil), cmd...) + gotStdin = append([]byte(nil), stdin...) + return &CLIResult{ + Stdout: `{"type":"result","result":"OK","session_id":"s1","num_turns":1}`, + ReturnCode: 0, + }, nil + } + + const prompt = "please reply with exactly OK" + raw, err := p.Execute(context.Background(), prompt, Options{ + Model: "haiku", + Tools: []string{"Read", "Write"}, + }) + require.NoError(t, err) + require.False(t, raw.IsError, "unexpected error: %s", raw.ErrorMessage) + + // 1. Prompt delivered via stdin. + assert.Equal(t, prompt, string(gotStdin), "prompt must be piped to the CLI on stdin") + + // 2. Prompt must not appear anywhere in the arg vector. + assert.NotContains(t, gotCmd, prompt, "prompt must not be passed as a CLI argument") + + // 3. The arg vector must end at the last --allowedTools value, never at a + // positional prompt — this is the exact regression being guarded. + require.NotEmpty(t, gotCmd) + assert.Equal(t, "Write", gotCmd[len(gotCmd)-1], + "arg vector must end at the last --allowedTools value, not a trailing positional prompt") + + // Sanity: the flags we expect are still present. + assert.Contains(t, gotCmd, "--allowedTools") + assert.Contains(t, gotCmd, "--print") + + // 4. Watchdog contract: the CLI must stream per-message events so RunCLI's + // idle watchdog sees progress — plain `--output-format json` is silent + // until completion and any turn quieter than the idle window was + // SIGKILLed mid-run. In --print mode the claude CLI hard-requires + // --verbose alongside stream-json. + assert.Contains(t, gotCmd, "--verbose") + for i, arg := range gotCmd { + if arg == "--output-format" { + require.Greater(t, len(gotCmd), i+1) + assert.Equal(t, "stream-json", gotCmd[i+1], + "output format must be stream-json to keep the idle watchdog fed") + } + } +} + +// TestClaudeCodeProvider_EmptyToolsStillStdin ensures the prompt is delivered on +// stdin even when no tools are set (no trailing positional in any case). +func TestClaudeCodeProvider_EmptyToolsStillStdin(t *testing.T) { + p := NewClaudeCodeProvider("claude") + + var gotCmd []string + var gotStdin []byte + p.runCLI = func(_ context.Context, cmd []string, _ map[string]string, _ string, _ int, stdin []byte) (*CLIResult, error) { + gotCmd = append([]string(nil), cmd...) + gotStdin = append([]byte(nil), stdin...) + return &CLIResult{Stdout: `{"type":"result","result":"OK"}`, ReturnCode: 0}, nil + } + + const prompt = "hello there" + _, err := p.Execute(context.Background(), prompt, Options{}) + require.NoError(t, err) + + assert.Equal(t, prompt, string(gotStdin)) + assert.NotContains(t, gotCmd, prompt) + require.NotEmpty(t, gotCmd) + assert.Equal(t, "--verbose", gotCmd[len(gotCmd)-1], + "vector ends at the base flags (--output-format stream-json --verbose), no positional prompt") +} + +// TestClaudeCodeProvider_ParsesStreamJSONFixture asserts the parser against a +// REAL captured stream: testdata/claude_stream_json_2.1.191.jsonl was produced +// by `claude --print --output-format stream-json --verbose --model haiku +// --allowedTools Read --allowedTools Write` (claude CLI 2.1.191) with the +// prompt "reply with just OK" delivered on stdin. Only environment-identifying +// inventories in the system/init event were sanitized; the event sequence and +// the terminal result event are byte-real. +// +// Contract (unchanged from the plain-json output format): +// - Result is the final result event's "result" text — not the raw stream. +// - SessionID / CostUSD / NumTurns come from the final result event. +// - Every stream event is retained in Messages (Python-provider parity: the +// Python claude provider appends every SDK stream message). +func TestClaudeCodeProvider_ParsesStreamJSONFixture(t *testing.T) { + fixture, err := os.ReadFile(filepath.Join("testdata", "claude_stream_json_2.1.191.jsonl")) + require.NoError(t, err) + + t.Run("parser consumes the raw capture", func(t *testing.T) { + raw := &RawResult{} + NewClaudeCodeProvider("").parseJSONOutput(string(fixture), raw) + + assert.Equal(t, "OK", raw.Result, "Result must be the final result event's text") + assert.Equal(t, "48a64026-56f9-4203-8220-1c099dd8378a", raw.Metrics.SessionID) + require.NotNil(t, raw.Metrics.CostUSD) + assert.InDelta(t, 0.0326441, *raw.Metrics.CostUSD, 1e-9) + assert.Equal(t, 1, raw.Metrics.NumTurns) + assert.Len(t, raw.Messages, 11, + "all stream events retained (init, rate_limit, thinking deltas, assistant, result)") + }) + + t.Run("Execute end-to-end over a subprocess emitting the capture", func(t *testing.T) { + dir := t.TempDir() + streamPath := filepath.Join(dir, "stream.jsonl") + require.NoError(t, os.WriteFile(streamPath, fixture, 0o644)) + script := writeTestScript(t, dir, "claude-stream", + "#!/bin/sh\ncat "+streamPath+"\n") + + raw, err := NewClaudeCodeProvider(script).Execute(context.Background(), "reply with just OK", Options{ + Tools: []string{"Read", "Write"}, + }) + require.NoError(t, err) + require.False(t, raw.IsError, "unexpected error: %s", raw.ErrorMessage) + assert.Equal(t, "OK", raw.Result) + assert.Equal(t, "48a64026-56f9-4203-8220-1c099dd8378a", raw.Metrics.SessionID) + require.NotNil(t, raw.Metrics.CostUSD) + assert.InDelta(t, 0.0326441, *raw.Metrics.CostUSD, 1e-9) + assert.Equal(t, 1, raw.Metrics.NumTurns) + }) +} diff --git a/sdk/go/harness/cli.go b/sdk/go/harness/cli.go index 3c5f864a5..d24ba2251 100644 --- a/sdk/go/harness/cli.go +++ b/sdk/go/harness/cli.go @@ -46,13 +46,27 @@ type CLIResult struct { ReturnCode int } -// RunCLI runs a CLI command and returns its output. The context controls -// cancellation; timeout is in seconds (0 means no timeout beyond ctx). +// RunCLI runs a CLI command with no standard input: the child sees an immediate +// EOF on stdin. It is a thin wrapper over RunCLIWithStdin; see that function for +// the full contract. +func RunCLI(ctx context.Context, cmd []string, env map[string]string, cwd string, timeout int) (*CLIResult, error) { + return RunCLIWithStdin(ctx, cmd, env, cwd, timeout, nil) +} + +// RunCLIWithStdin runs a CLI command and returns its output. The context +// controls cancellation; timeout is in seconds (0 means no timeout beyond ctx). +// +// stdin is fed to the child process's standard input. A nil (or empty) slice +// yields an immediate EOF — identical to RunCLI. Providers whose CLI reads the +// prompt from stdin (e.g. `claude --print`) pass the prompt bytes here instead +// of appending them as a trailing positional argument, which a preceding +// variadic flag (e.g. claude's `--allowedTools`) would otherwise greedily +// consume — leaving the CLI with no prompt and exiting non-zero. // // Environment merging: entries in env are merged with os.Environ(). An empty // string value ("") causes that variable to be removed from the environment // rather than set to empty — use this to unset inherited variables. -func RunCLI(ctx context.Context, cmd []string, env map[string]string, cwd string, timeout int) (*CLIResult, error) { +func RunCLIWithStdin(ctx context.Context, cmd []string, env map[string]string, cwd string, timeout int, stdin []byte) (*CLIResult, error) { if timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second) @@ -94,14 +108,26 @@ func RunCLI(ctx context.Context, cmd []string, env map[string]string, cwd string c.Dir = cwd } - // Explicit null stdin so the child gets an immediate EOF instead of - // inheriting the parent's stdin (a hang risk if the child reads stdin). - c.Stdin = bytes.NewReader(nil) + // Feed the provided stdin to the child. A nil/empty slice yields an + // immediate EOF, so the child never inherits the parent's stdin (a hang + // risk if the child blocks reading stdin). When the child exits without + // draining stdin, the copy fails with EPIPE, which os/exec ignores. + c.Stdin = bytes.NewReader(stdin) // Run the child in its own process group so the idle watchdog can kill // the whole tree, not just the leader. setProcessGroup is platform-guarded. setProcessGroup(c) + // On ctx cancellation/timeout, kill the whole process group too. + // exec.CommandContext's default Cancel only kills the group LEADER, which + // orphans grandchildren (e.g. MCP servers spawned by the claude CLI) when + // a caller times out or cancels. On Windows killProcessGroup degrades to + // killing just the leader, matching the previous behavior. + c.Cancel = func() error { + killProcessGroup(c) + return nil + } + stdoutPipe, err := c.StdoutPipe() if err != nil { return nil, err diff --git a/sdk/go/harness/testdata/claude_stream_json_2.1.191.jsonl b/sdk/go/harness/testdata/claude_stream_json_2.1.191.jsonl new file mode 100644 index 000000000..508cec1c2 --- /dev/null +++ b/sdk/go/harness/testdata/claude_stream_json_2.1.191.jsonl @@ -0,0 +1,11 @@ +{"type":"system","subtype":"init","cwd":"/tmp/claude-stream-capture","session_id":"48a64026-56f9-4203-8220-1c099dd8378a","tools":["Task","Bash","Edit","Read","Write","WebFetch","WebSearch"],"mcp_servers":[],"model":"claude-haiku-4-5-20251001","permissionMode":"default","slash_commands":["compact","config","init","review"],"apiKeySource":"none","claude_code_version":"2.1.191","output_style":"default","agents":["general-purpose"],"skills":[],"plugins":[],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"093be5bc-c3c1-4c15-a987-32931c15cf8c","memory_paths":{"auto":"/home/user/.claude/projects/-tmp-claude-stream-capture/memory/"},"fast_mode_state":"off"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1783637400,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"fd5057fe-eddf-4c86-894e-c94ffcbb5cac","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":3,"estimated_tokens_delta":3,"uuid":"b780f966-907e-49ef-91a6-e219d7b6b14a","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":34,"estimated_tokens_delta":31,"uuid":"689c2271-a580-4511-9358-221f1760a104","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":47,"estimated_tokens_delta":13,"uuid":"a33e7b09-abd3-481e-98e4-780e1df4177a","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":66,"estimated_tokens_delta":19,"uuid":"e1635c75-4610-4ac7-bee9-f8c5516dc3e4","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":71,"estimated_tokens_delta":5,"uuid":"aee5414e-4bc0-445b-b035-c76cee1da9b4","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":133,"estimated_tokens_delta":62,"uuid":"cd60e718-28dc-460f-b539-37d2af06a1d5","session_id":"48a64026-56f9-4203-8220-1c099dd8378a"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CcsFkPnSTKPbPn3BwpPgx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user is asking me to reply with just \"OK\". This is a simple acknowledgment message. They've provided a lot of context about their instructions, rules, and settings, and now they're asking me to confirm I've read/understood it.\n\nI should simply respond with \"OK\" as requested.","signature":"Eo4ECpMBCA8YAipA30vWEZusHgXOU3c1q0XdLMtLPWzSbl5slFx+mjaiQLlscaSO6pucWWgwoOc0IonZFxKqxwu3tRt3stnxQZVzgjIZY2xhdWRlLWhhaWt1LTQtNS0yMDI1MTAwMTgAQgh0aGlua2luZ1okZWMwNGY0YzAtZTg1Ny00NGUyLWJhYTctNTY0YmM3ZDMxMjA3EgzjsundiqL02SWA3RQaDACiFjgKdgpHE0on0SIwSeS/uMZr34q3eZNbmpuk8NNLX0eVRJSpJQETSednE9VSO1qSNruN91bd/ez/GODMKqcCaUJDu7MAV+DVAh+5rUMSQesLeKTbOZn0tY3Dql2WxiL3JuMce6vPfsAGvvgLje+iLQP7Li3LtBbSyW5HdcEq1ih4i07Cq5N22nUBVdMx8GfXJE8eTHptJgJIrT68O3IgPXIXyTaeQ+rZBxriS5kYJZ8ATlShcO0REheNjxMpYg+L6Vfq3KGFz6CPmkKIBoKgr8ffRCniJ22h7YEsO7HsQXflk6c5wvLUHfpwlcHHtPaM/EsGglKECuGixrpXn3eNp+Kqm1VX7k/QEBZVmaRlix1n1atrKYeWRlmIo/E5j77foRA4g2fhH9oZye4BY8U1x2Z9Tu+2gghx5oCi80Qsmidvmh8PMF61bZGwTa719RiILVoOyDE5Mq+U0XlooiW28LhSUbTe6xgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":15023,"cache_read_input_tokens":16491,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":15023},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"48a64026-56f9-4203-8220-1c099dd8378a","uuid":"5abc9419-7bda-49d9-ad12-a134d4d4d69e","request_id":"req_011CcsFkNqPavEi1KdjEMJRw"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CcsFkPnSTKPbPn3BwpPgx","type":"message","role":"assistant","content":[{"type":"text","text":"OK"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":15023,"cache_read_input_tokens":16491,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":15023},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"48a64026-56f9-4203-8220-1c099dd8378a","uuid":"40791e35-0359-433d-80b5-03175d5a88c6","request_id":"req_011CcsFkNqPavEi1KdjEMJRw"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":2002,"duration_api_ms":2725,"ttft_ms":1929,"ttft_stream_ms":1071,"time_to_request_ms":76,"num_turns":1,"result":"OK","stop_reason":"end_turn","session_id":"48a64026-56f9-4203-8220-1c099dd8378a","total_cost_usd":0.0326441,"usage":{"input_tokens":10,"cache_creation_input_tokens":15023,"cache_read_input_tokens":16491,"output_tokens":75,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":15023,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":10,"output_tokens":75,"cache_read_input_tokens":16491,"cache_creation_input_tokens":15023,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":15023},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":514,"outputTokens":87,"cacheReadInputTokens":16491,"cacheCreationInputTokens":15023,"webSearchRequests":0,"costUSD":0.0326441,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"de7a0e6f-3173-4bf9-a79e-7f5ff9c2193c"}