diff --git a/README.md b/README.md index beaaf17..d015567 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,10 @@ responses OpenAI Responses API /v1/responses ``` The Responses API client converts the conversation history to Responses input -items (`user`/`assistant`/`system` messages, `function_call`, and -`function_call_output`), streams `response.output_text.delta` events, and -supports function calling through `response.output_item.added`, +items (`user`/`assistant` messages, `function_call`, and +`function_call_output`), sends the system prompt as the top-level +`instructions` field, streams `response.output_text.delta` events, and supports +function calling through `response.output_item.added`, `response.function_call_arguments.delta`, and `response.output_item.done`. `models` is required. `/model model ` is restricted to that list. Each @@ -98,10 +99,11 @@ none, middle, high, xhigh, max ``` When configured or changed with `/model thinking ...`, the value is sent as a -top-level request field named `thinking`. For OpenAI reasoning models on the -Responses API, set `thinking_param: reasoning` so the level is sent as the -official `reasoning: {"effort": ...}` field; agentu maps its `middle` level to -the official `medium` value and passes `none`, `high`, `xhigh`, `max` through. +top-level request field. The default field depends on `api_type`: `chat` uses +`thinking`, while `responses` uses the official `reasoning` field, sent as +`{"effort": ...}` with agentu's `middle` level mapped to the official `medium` +value (`none`, `high`, `xhigh`, `max` pass through). Override with +`thinking_param` if a provider expects a different field name. ## CLI Flags diff --git a/internal/session/manager.go b/internal/session/manager.go index ba581a6..6748460 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -68,11 +68,29 @@ func (m *Manager) Resume(id string) error { return err } m.currentSession = sess + m.restoreRuntime(sess) m.agent.SetMessages(sess.Messages) m.agent.SetLastUsage(sess.LastUsage) return nil } +// restoreRuntime switches the manager and agent back to the provider, model, +// and thinking level recorded on a resumed session. Values that are no longer +// valid for the current config are skipped and the defaults remain active. +func (m *Manager) restoreRuntime(sess *Session) { + if sess.Provider != "" && sess.Provider != m.providerName { + if _, ok := m.cfg.Providers[sess.Provider]; ok { + _, _ = m.SetProvider(sess.Provider) + } + } + if sess.Model != "" && sess.Model != m.provider.Model { + _, _ = m.SetModel(sess.Model) + } + if sess.Thinking != "" && sess.Thinking != m.provider.Thinking { + _, _ = m.SetThinking(sess.Thinking) + } +} + // Save persists the current session to disk. func (m *Manager) Save() error { if m.store == nil || m.currentSession == nil { @@ -82,6 +100,8 @@ func (m *Manager) Save() error { m.currentSession.LastUsage = m.agent.LastUsage() m.currentSession.Provider = m.providerName m.currentSession.Model = m.provider.Model + m.currentSession.APIType = m.provider.APIType + m.currentSession.Thinking = m.provider.Thinking // Auto-name if empty if m.currentSession.Name == "" { m.currentSession.Name = DefaultName(m.currentSession.Messages) @@ -150,6 +170,8 @@ func (m *Manager) NewSession() (string, error) { m.currentSession.Messages = msgs m.currentSession.Provider = m.providerName m.currentSession.Model = m.provider.Model + m.currentSession.APIType = m.provider.APIType + m.currentSession.Thinking = m.provider.Thinking if m.currentSession.Name == "" { m.currentSession.Name = DefaultName(msgs) } @@ -257,7 +279,7 @@ func (m *Manager) SetThinking(level string) (string, error) { m.provider.Thinking = level m.provider.ThinkingConfigured = true if m.provider.ThinkingParam == "" { - m.provider.ThinkingParam = "thinking" + m.provider.ThinkingParam = config.DefaultThinkingParam(m.provider.APIType) } m.cfg.Providers[m.providerName] = m.provider m.syncAgent(nil) diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index de5186a..ad68b79 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -403,3 +403,132 @@ func TestManagerUsesModelContextForAgentCompaction(t *testing.T) { t.Fatalf("answerCalls = %d, want 1", provider.answerCalls) } } + +func TestManagerResumeRestoresRuntime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"ok"}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}` + "\n\n")) + })) + defer server.Close() + + cfg := &config.Config{ + Providers: map[string]config.ProviderConfig{ + "chat": { + Name: "chat", + APIType: config.APITypeChat, + BaseURL: "https://chat.example.com", + APIKey: "sk-test", + Model: "model-a", + Models: []string{"model-a"}, + Thinking: "none", + ThinkingParam: "thinking", + ThinkingConfigured: true, + }, + "resp": { + Name: "resp", + APIType: config.APITypeResponses, + BaseURL: server.URL, + APIKey: "sk-test", + Model: "m1", + Models: []string{"m1", "m2"}, + Thinking: "none", + ThinkingParam: "reasoning", + ThinkingConfigured: true, + }, + }, + } + assistant := agent.New(agent.Options{ + Provider: &recordingProvider{}, + Model: "model-a", + Thinking: "none", + ThinkingParam: "thinking", + ThinkingEnabled: true, + }) + store, err := NewStore(filepath.Join(t.TempDir(), "sessions")) + if err != nil { + t.Fatal(err) + } + manager, err := NewManager(cfg, assistant, server.Client(), store) + if err != nil { + t.Fatal(err) + } + + // Build a session on the responses provider with a custom model and thinking. + manager.newSession() + if _, err := manager.SetProvider("resp"); err != nil { + t.Fatal(err) + } + if _, err := manager.SetModel("m2"); err != nil { + t.Fatal(err) + } + if _, err := manager.SetThinking("xhigh"); err != nil { + t.Fatal(err) + } + if err := manager.Save(); err != nil { + t.Fatal(err) + } + id := manager.CurrentSessionID() + + // Drift away from the saved runtime. + if _, err := manager.SetProvider("chat"); err != nil { + t.Fatal(err) + } + if _, err := manager.SetModel("model-a"); err != nil { + t.Fatal(err) + } + if _, err := manager.SetThinking("none"); err != nil { + t.Fatal(err) + } + + if err := manager.Resume(id); err != nil { + t.Fatal(err) + } + if got := manager.CurrentProvider(); got != "resp" { + t.Fatalf("provider = %q, want resp", got) + } + if got := manager.CurrentModel(); got != "m2" { + t.Fatalf("model = %q, want m2", got) + } + if got := manager.CurrentThinking(); got != "xhigh" { + t.Fatalf("thinking = %q, want xhigh", got) + } + if got := manager.CurrentAPIType(); got != config.APITypeResponses { + t.Fatalf("api type = %q, want responses", got) + } + runtime := assistant.RuntimeInfo() + if runtime.ProviderName != "resp" || runtime.Model != "m2" || runtime.Thinking != "xhigh" { + t.Fatalf("agent runtime = %#v", runtime) + } +} + +func TestManagerResumeFallsBackWhenRuntimeMissing(t *testing.T) { + manager, assistant, _ := testManager(t) + + sess := &Session{ + ID: GenerateID(), + Provider: "ghost", + Model: "ghost-model", + Thinking: "xhigh", + Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}}, + } + if err := manager.store.Save(sess); err != nil { + t.Fatal(err) + } + if err := manager.Resume(sess.ID); err != nil { + t.Fatal(err) + } + if got := manager.CurrentProvider(); got != "one" { + t.Fatalf("provider = %q, want default one", got) + } + if got := manager.CurrentModel(); got != "model-a" { + t.Fatalf("model = %q, want default model-a", got) + } + if got := manager.CurrentThinking(); got != "xhigh" { + t.Fatalf("thinking = %q, want restored xhigh", got) + } + runtime := assistant.RuntimeInfo() + if runtime.ProviderName != "one" || runtime.Model != "model-a" { + t.Fatalf("agent runtime = %#v", runtime) + } +} diff --git a/internal/session/store.go b/internal/session/store.go index a26db28..ce5dd21 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -22,6 +22,8 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` Provider string `json:"provider"` Model string `json:"model"` + APIType string `json:"api_type,omitempty"` + Thinking string `json:"thinking,omitempty"` Messages []llm.Message `json:"messages"` LastUsage *llm.Usage `json:"last_usage,omitempty"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index 57257b9..42f05c3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -241,7 +241,7 @@ func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig { } applyModelDefaults(&provider) if provider.ThinkingConfigured && provider.ThinkingParam == "" { - provider.ThinkingParam = "thinking" + provider.ThinkingParam = DefaultThinkingParam(provider.APIType) } return provider } @@ -268,6 +268,17 @@ func IsAPIType(value string) bool { return false } +// DefaultThinkingParam returns the default request field used for the +// thinking level. Chat-completions providers historically use a top-level +// "thinking" field; the OpenAI Responses API uses "reasoning" with an +// {"effort": ...} value. +func DefaultThinkingParam(apiType string) string { + if apiType == APITypeResponses { + return "reasoning" + } + return "thinking" +} + func normalizeModelConfigs(rawModels []rawModelConfig) ([]string, map[string]ModelConfig) { models := make([]string, 0, len(rawModels)) configs := make(map[string]ModelConfig, len(rawModels)) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2f54c7a..2215935 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -441,3 +441,39 @@ providers: t.Fatalf("err = %v", err) } } + +func TestLoadDefaultsThinkingParamByAPIType(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTU_TEST_KEY", "sk-test") + path := filepath.Join(dir, "agentu.yaml") + if err := os.WriteFile(path, []byte(` +providers: + chat: + api_type: chat + base_url: https://chat.example.com + api_key: ${AGENTU_TEST_KEY} + models: + - id: chat-model + thinking: none + responses: + api_type: responses + base_url: https://responses.example.com + api_key: ${AGENTU_TEST_KEY} + models: + - id: responses-model + thinking: high +`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.Providers["chat"].ThinkingParam; got != "thinking" { + t.Fatalf("chat thinking param = %q, want %q", got, "thinking") + } + if got := cfg.Providers["responses"].ThinkingParam; got != "reasoning" { + t.Fatalf("responses thinking param = %q, want %q", got, "reasoning") + } +} diff --git a/pkg/llm/responses.go b/pkg/llm/responses.go index 1c56008..f2da386 100644 --- a/pkg/llm/responses.go +++ b/pkg/llm/responses.go @@ -124,6 +124,9 @@ func buildResponsesRequest(req ChatRequest) map[string]any { "input": buildResponsesInput(req.Messages), "stream": true, } + if instructions := responsesInstructions(req.Messages); instructions != "" { + payload["instructions"] = instructions + } if len(req.Tools) > 0 { payload["tools"] = buildResponsesTools(req.Tools) } @@ -153,7 +156,9 @@ func buildResponsesInput(messages []Message) []any { for _, msg := range messages { switch msg.Role { case RoleSystem: - items = append(items, responsesMessageInput{Role: "system", Content: msg.Content}) + // System instructions are sent as the top-level "instructions" + // field and must not be duplicated inside the input array. + continue case RoleUser: items = append(items, responsesMessageInput{Role: "user", Content: msg.Content}) case RoleAssistant: @@ -179,6 +184,22 @@ func buildResponsesInput(messages []Message) []any { return items } +// responsesInstructions extracts system messages into a single instructions +// string, which is the OpenAI Responses API way to pass developer/system +// guidance (and the most prompt-cache friendly). +func responsesInstructions(messages []Message) string { + var instructions []string + for _, msg := range messages { + if msg.Role != RoleSystem { + continue + } + if text := strings.TrimSpace(msg.Content); text != "" { + instructions = append(instructions, text) + } + } + return strings.Join(instructions, "\n\n") +} + func buildResponsesTools(tools []Tool) []responsesTool { out := make([]responsesTool, 0, len(tools)) for _, tool := range tools { diff --git a/pkg/llm/responses_test.go b/pkg/llm/responses_test.go index 31e9335..e912557 100644 --- a/pkg/llm/responses_test.go +++ b/pkg/llm/responses_test.go @@ -59,12 +59,15 @@ func TestBuildResponsesRequest(t *testing.T) { if raw["model"] != "gpt-test" || raw["stream"] != true { t.Fatalf("model/stream = %#v / %#v", raw["model"], raw["stream"]) } + if raw["instructions"] != "be concise" { + t.Fatalf("instructions = %#v", raw["instructions"]) + } if raw["tool_choice"] != "auto" { t.Fatalf("tool_choice = %#v", raw["tool_choice"]) } input, ok := raw["input"].([]any) - if !ok || len(input) != 6 { + if !ok || len(input) != 5 { t.Fatalf("input = %#v", raw["input"]) } checkInputItem := func(index int, wantType, wantKey, wantValue string) { @@ -80,16 +83,15 @@ func TestBuildResponsesRequest(t *testing.T) { t.Fatalf("input[%d].type = %#v, want %q", index, item["type"], wantType) } } - checkInputItem(0, "", "role", "system") - checkInputItem(1, "", "role", "user") - checkInputItem(2, "", "role", "assistant") - checkInputItem(3, "function_call", "call_id", "call_1") - checkInputItem(4, "function_call_output", "call_id", "call_1") - checkInputItem(5, "", "role", "assistant") - if item := input[3].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` { + checkInputItem(0, "", "role", "user") + checkInputItem(1, "", "role", "assistant") + checkInputItem(2, "function_call", "call_id", "call_1") + checkInputItem(3, "function_call_output", "call_id", "call_1") + checkInputItem(4, "", "role", "assistant") + if item := input[2].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` { t.Fatalf("function_call item = %#v", item) } - if item := input[4].(map[string]any); item["output"] != "contents" { + if item := input[3].(map[string]any); item["output"] != "contents" { t.Fatalf("function_call_output item = %#v", item) } @@ -117,6 +119,36 @@ func TestBuildResponsesRequest(t *testing.T) { } } +func TestResponsesInstructionsCombinesSystemMessages(t *testing.T) { + payload := buildResponsesRequest(ChatRequest{ + Model: "gpt-test", + Messages: []Message{ + {Role: RoleSystem, Content: "first instruction"}, + {Role: RoleUser, Content: "hi"}, + {Role: RoleSystem, Content: "second instruction"}, + }, + }) + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if raw["instructions"] != "first instruction\n\nsecond instruction" { + t.Fatalf("instructions = %#v", raw["instructions"]) + } + input, ok := raw["input"].([]any) + if !ok || len(input) != 1 { + t.Fatalf("input = %#v", raw["input"]) + } + item, ok := input[0].(map[string]any) + if !ok || item["role"] != "user" { + t.Fatalf("input[0] = %#v", input[0]) + } +} + func TestOpenAIResponsesClientStreamsContentAndToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/responses" {