diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 07:11:59 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 07:11:59 +0000 |
| commit | 301e7a66387f99ab76754d08bca42f4a9930d3b1 (patch) | |
| tree | 35815d99028d53f99b800a91437b455f043830bc /internal/llm/client_test.go | |
| parent | 65cd7ea65d9c6fe0fad39bb2c5cac70d61153444 (diff) | |
feat(executor,llm): LocalRunner agent-channel via OpenAI tool-use (Phase 5)
LocalRunner previously ignored the AgentChannel and produced a single
fire-and-forget completion. It now declares the four agent back-channel tools
(ask_user/report_summary/spawn_subtask/record_progress) as OpenAI
function-calling definitions and runs a tool-use loop: each turn feeds tool
results back as message history (re-feed) until the model stops calling tools,
bounded by maxLocalToolTurns. ask_user converts a buffered question into a
*BlockedError so the task blocks like the container runners.
Adds tool-use support to the llm client (Tool/ToolCall/ToolFunction types,
Tools on ChatRequest, ToolCalls on ChatResponse + wire request/response). The
loop uses non-streaming Chat (tool_calls don't stream cleanly); assistant text
is still written to stdout.log in the Claude stream-json envelope so summary/
changestats parsing is unchanged.
Fully tested against a mock OpenAI endpoint + storeChannel: spawn/summary/
progress dispatch, ask_user blocking, token accumulation, and the llm tools
round-trip. NOTE: local resume re-feeds conversation state (Decision #8) — not
yet wired, so a blocked local task resumes fresh for now.
https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/llm/client_test.go')
| -rw-r--r-- | internal/llm/client_test.go | 40 |
1 files changed, 40 insertions, 0 deletions
diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 8257836..7533a8a 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -157,3 +157,43 @@ func TestErrFromStatus_RateLimitMarker(t *testing.T) { t.Errorf("error should embed retry-after, got: %v", err) } } + +func TestChat_ToolsRoundTrip(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body openAIRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Tools) != 1 || body.Tools[0].Function.Name != "do_thing" { + t.Errorf("tools not forwarded in request: %+v", body.Tools) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{ + "model": "m", + "choices": [{"message": {"role": "assistant", "content": "", + "tool_calls": [{"id": "call_1", "type": "function", + "function": {"name": "do_thing", "arguments": "{\"x\":1}"}}]}, + "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 4} + }`) + })) + defer srv.Close() + + c := &Client{Endpoint: srv.URL + "/v1", Model: "m"} + resp, err := c.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "go"}}, + Tools: []Tool{{Type: "function", Function: ToolFunction{ + Name: "do_thing", Description: "d", Parameters: map[string]any{"type": "object"}, + }}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "call_1" || tc.Function.Name != "do_thing" || tc.Function.Arguments != `{"x":1}` { + t.Errorf("unexpected tool call parsed: %+v", tc) + } +} |
