1 Commits

Author SHA1 Message Date
loveuer 28195519d1 feat(session,llm): restore session runtime on resume; align Responses API details 2026-08-13 13:46:26 +08:00
8 changed files with 274 additions and 19 deletions
+9 -7
View File
@@ -74,9 +74,10 @@ responses OpenAI Responses API /v1/responses
``` ```
The Responses API client converts the conversation history to Responses input The Responses API client converts the conversation history to Responses input
items (`user`/`assistant`/`system` messages, `function_call`, and items (`user`/`assistant` messages, `function_call`, and
`function_call_output`), streams `response.output_text.delta` events, and `function_call_output`), sends the system prompt as the top-level
supports function calling through `response.output_item.added`, `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`. `response.function_call_arguments.delta`, and `response.output_item.done`.
`models` is required. `/model model <name>` is restricted to that list. Each `models` is required. `/model model <name>` 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 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 top-level request field. The default field depends on `api_type`: `chat` uses
Responses API, set `thinking_param: reasoning` so the level is sent as the `thinking`, while `responses` uses the official `reasoning` field, sent as
official `reasoning: {"effort": ...}` field; agentu maps its `middle` level to `{"effort": ...}` with agentu's `middle` level mapped to the official `medium`
the official `medium` value and passes `none`, `high`, `xhigh`, `max` through. value (`none`, `high`, `xhigh`, `max` pass through). Override with
`thinking_param` if a provider expects a different field name.
## CLI Flags ## CLI Flags
+23 -1
View File
@@ -68,11 +68,29 @@ func (m *Manager) Resume(id string) error {
return err return err
} }
m.currentSession = sess m.currentSession = sess
m.restoreRuntime(sess)
m.agent.SetMessages(sess.Messages) m.agent.SetMessages(sess.Messages)
m.agent.SetLastUsage(sess.LastUsage) m.agent.SetLastUsage(sess.LastUsage)
return nil 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. // Save persists the current session to disk.
func (m *Manager) Save() error { func (m *Manager) Save() error {
if m.store == nil || m.currentSession == nil { if m.store == nil || m.currentSession == nil {
@@ -82,6 +100,8 @@ func (m *Manager) Save() error {
m.currentSession.LastUsage = m.agent.LastUsage() m.currentSession.LastUsage = m.agent.LastUsage()
m.currentSession.Provider = m.providerName m.currentSession.Provider = m.providerName
m.currentSession.Model = m.provider.Model m.currentSession.Model = m.provider.Model
m.currentSession.APIType = m.provider.APIType
m.currentSession.Thinking = m.provider.Thinking
// Auto-name if empty // Auto-name if empty
if m.currentSession.Name == "" { if m.currentSession.Name == "" {
m.currentSession.Name = DefaultName(m.currentSession.Messages) m.currentSession.Name = DefaultName(m.currentSession.Messages)
@@ -150,6 +170,8 @@ func (m *Manager) NewSession() (string, error) {
m.currentSession.Messages = msgs m.currentSession.Messages = msgs
m.currentSession.Provider = m.providerName m.currentSession.Provider = m.providerName
m.currentSession.Model = m.provider.Model m.currentSession.Model = m.provider.Model
m.currentSession.APIType = m.provider.APIType
m.currentSession.Thinking = m.provider.Thinking
if m.currentSession.Name == "" { if m.currentSession.Name == "" {
m.currentSession.Name = DefaultName(msgs) m.currentSession.Name = DefaultName(msgs)
} }
@@ -257,7 +279,7 @@ func (m *Manager) SetThinking(level string) (string, error) {
m.provider.Thinking = level m.provider.Thinking = level
m.provider.ThinkingConfigured = true m.provider.ThinkingConfigured = true
if m.provider.ThinkingParam == "" { if m.provider.ThinkingParam == "" {
m.provider.ThinkingParam = "thinking" m.provider.ThinkingParam = config.DefaultThinkingParam(m.provider.APIType)
} }
m.cfg.Providers[m.providerName] = m.provider m.cfg.Providers[m.providerName] = m.provider
m.syncAgent(nil) m.syncAgent(nil)
+129
View File
@@ -403,3 +403,132 @@ func TestManagerUsesModelContextForAgentCompaction(t *testing.T) {
t.Fatalf("answerCalls = %d, want 1", provider.answerCalls) 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)
}
}
+2
View File
@@ -22,6 +22,8 @@ type Session struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Provider string `json:"provider"` Provider string `json:"provider"`
Model string `json:"model"` Model string `json:"model"`
APIType string `json:"api_type,omitempty"`
Thinking string `json:"thinking,omitempty"`
Messages []llm.Message `json:"messages"` Messages []llm.Message `json:"messages"`
LastUsage *llm.Usage `json:"last_usage,omitempty"` LastUsage *llm.Usage `json:"last_usage,omitempty"`
} }
+12 -1
View File
@@ -241,7 +241,7 @@ func normalizeProvider(name string, raw rawProviderConfig) ProviderConfig {
} }
applyModelDefaults(&provider) applyModelDefaults(&provider)
if provider.ThinkingConfigured && provider.ThinkingParam == "" { if provider.ThinkingConfigured && provider.ThinkingParam == "" {
provider.ThinkingParam = "thinking" provider.ThinkingParam = DefaultThinkingParam(provider.APIType)
} }
return provider return provider
} }
@@ -268,6 +268,17 @@ func IsAPIType(value string) bool {
return false 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) { func normalizeModelConfigs(rawModels []rawModelConfig) ([]string, map[string]ModelConfig) {
models := make([]string, 0, len(rawModels)) models := make([]string, 0, len(rawModels))
configs := make(map[string]ModelConfig, len(rawModels)) configs := make(map[string]ModelConfig, len(rawModels))
+36
View File
@@ -441,3 +441,39 @@ providers:
t.Fatalf("err = %v", err) 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")
}
}
+22 -1
View File
@@ -124,6 +124,9 @@ func buildResponsesRequest(req ChatRequest) map[string]any {
"input": buildResponsesInput(req.Messages), "input": buildResponsesInput(req.Messages),
"stream": true, "stream": true,
} }
if instructions := responsesInstructions(req.Messages); instructions != "" {
payload["instructions"] = instructions
}
if len(req.Tools) > 0 { if len(req.Tools) > 0 {
payload["tools"] = buildResponsesTools(req.Tools) payload["tools"] = buildResponsesTools(req.Tools)
} }
@@ -153,7 +156,9 @@ func buildResponsesInput(messages []Message) []any {
for _, msg := range messages { for _, msg := range messages {
switch msg.Role { switch msg.Role {
case RoleSystem: 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: case RoleUser:
items = append(items, responsesMessageInput{Role: "user", Content: msg.Content}) items = append(items, responsesMessageInput{Role: "user", Content: msg.Content})
case RoleAssistant: case RoleAssistant:
@@ -179,6 +184,22 @@ func buildResponsesInput(messages []Message) []any {
return items 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 { func buildResponsesTools(tools []Tool) []responsesTool {
out := make([]responsesTool, 0, len(tools)) out := make([]responsesTool, 0, len(tools))
for _, tool := range tools { for _, tool := range tools {
+41 -9
View File
@@ -59,12 +59,15 @@ func TestBuildResponsesRequest(t *testing.T) {
if raw["model"] != "gpt-test" || raw["stream"] != true { if raw["model"] != "gpt-test" || raw["stream"] != true {
t.Fatalf("model/stream = %#v / %#v", raw["model"], raw["stream"]) 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" { if raw["tool_choice"] != "auto" {
t.Fatalf("tool_choice = %#v", raw["tool_choice"]) t.Fatalf("tool_choice = %#v", raw["tool_choice"])
} }
input, ok := raw["input"].([]any) input, ok := raw["input"].([]any)
if !ok || len(input) != 6 { if !ok || len(input) != 5 {
t.Fatalf("input = %#v", raw["input"]) t.Fatalf("input = %#v", raw["input"])
} }
checkInputItem := func(index int, wantType, wantKey, wantValue string) { 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) t.Fatalf("input[%d].type = %#v, want %q", index, item["type"], wantType)
} }
} }
checkInputItem(0, "", "role", "system") checkInputItem(0, "", "role", "user")
checkInputItem(1, "", "role", "user") checkInputItem(1, "", "role", "assistant")
checkInputItem(2, "", "role", "assistant") checkInputItem(2, "function_call", "call_id", "call_1")
checkInputItem(3, "function_call", "call_id", "call_1") checkInputItem(3, "function_call_output", "call_id", "call_1")
checkInputItem(4, "function_call_output", "call_id", "call_1") checkInputItem(4, "", "role", "assistant")
checkInputItem(5, "", "role", "assistant") if item := input[2].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` {
if item := input[3].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` {
t.Fatalf("function_call item = %#v", item) 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) 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) { func TestOpenAIResponsesClientStreamsContentAndToolCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/responses" { if r.URL.Path != "/v1/responses" {