feat(core): stabilize dynamic prompt context
- commit
- 808bb6548eea910d912ac3d1396ad840a8cba95a
- author
- brice · · 2026-08-18 08:34 UTC
- parent
- 5d2b756
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +270 -78
M docs/providers.md +22 -11
| @@ -150,22 +150,33 @@ profile sets `fastMode` explicitly. | |||
| 150 | 150 | ||
| 151 | 151 | ## Session identity and caching | |
| 152 | 152 | ||
| 153 | - | zut supplies every provider request with a stable logical session ID and a | |
| 154 | - | per-prompt turn ID. They are opaque local correlations; adapters translate them | |
| 155 | - | only when their exact provider, model, and endpoint declaration supports it. | |
| 156 | - | For example, the OpenAI/Codex Responses route uses the stable session as its | |
| 157 | - | `prompt_cache_key` and, where required by the declared Codex route, its | |
| 158 | - | `session-id` header. A generic OpenAI-compatible endpoint never receives those | |
| 159 | - | extensions merely because it accepts an OpenAI-shaped request. | |
| 153 | + | zut supplies every provider request with a root cache identity, a | |
| 154 | + | conversation-thread identity, and a per-prompt turn ID. They are opaque local | |
| 155 | + | correlations; adapters translate them only when their exact provider, model, | |
| 156 | + | and endpoint declaration supports it. Resident children share the root cache | |
| 157 | + | identity but retain distinct thread identities. The OpenAI/Codex Responses | |
| 158 | + | route uses the root identity as `prompt_cache_key`; declared Codex routes also | |
| 159 | + | receive their documented session and thread routing headers. A generic | |
| 160 | + | OpenAI-compatible endpoint never receives these extensions merely because it | |
| 161 | + | accepts an OpenAI-shaped request. | |
| 162 | + | ||
| 163 | + | When a provider reports cache detail, zut displays cache-read hit rate as | |
| 164 | + | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | |
| 165 | + | cache miss; no percentage means the provider or historical session did not | |
| 166 | + | report cache detail. Cache writes remain separate from that hit rate. Provider | |
| 167 | + | retention and eviction, request routing, and OpenAI's 1,024-token eligibility | |
| 168 | + | minimum can still produce a miss even when the stable prefix is unchanged. | |
| 160 | 169 | ||
| 161 | 170 | ### OpenAI Responses WebSocket mode | |
| 162 | 171 | ||
| 163 | 172 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | |
| 164 | 173 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | |
| 165 | - | connection per logical session, sends one `response.create` at a time, and | |
| 166 | - | continues the most recent completed response with `previous_response_id` plus | |
| 167 | - | only new tool or user input. The request uses `store:false`; its only | |
| 168 | - | provider-specific handshake header is `Authorization: Bearer ...`, never the | |
| 174 | + | connection per conversation thread, sends one `response.create` at a time, | |
| 175 | + | and continues the most recent completed response with `previous_response_id` | |
| 176 | + | only when the complete response-context configuration and prior input prefix | |
| 177 | + | match exactly. Compatible continuations send only new tool or user input. | |
| 178 | + | The request uses `store:false`; its only provider-specific handshake header is | |
| 179 | + | `Authorization: Bearer ...`, never the | |
| 169 | 180 | ChatGPT/Codex account, routing, or session headers. | |
| 170 | 181 | ||
| 171 | 182 | The capability is deliberately not inherited by `--base-url`, compatible | |
| @@ -150,22 +150,33 @@ profile sets `fastMode` explicitly. | |||||
| 150 | 150 | ||||
| 151 | ## Session identity and caching | 151 | ## Session identity and caching | ||
| 152 | 152 | ||||
| 153 | - | zut supplies every provider request with a stable logical session ID and a | 153 | + | zut supplies every provider request with a root cache identity, a |
| 154 | - | per-prompt turn ID. They are opaque local correlations; adapters translate them | 154 | + | conversation-thread identity, and a per-prompt turn ID. They are opaque local |
| 155 | - | only when their exact provider, model, and endpoint declaration supports it. | 155 | + | correlations; adapters translate them only when their exact provider, model, |
| 156 | - | For example, the OpenAI/Codex Responses route uses the stable session as its | 156 | + | and endpoint declaration supports it. Resident children share the root cache |
| 157 | - | `prompt_cache_key` and, where required by the declared Codex route, its | 157 | + | identity but retain distinct thread identities. The OpenAI/Codex Responses |
| 158 | - | `session-id` header. A generic OpenAI-compatible endpoint never receives those | 158 | + | route uses the root identity as `prompt_cache_key`; declared Codex routes also |
| 159 | - | extensions merely because it accepts an OpenAI-shaped request. | 159 | + | receive their documented session and thread routing headers. A generic |
| 160 | + | OpenAI-compatible endpoint never receives these extensions merely because it | |||
| 161 | + | accepts an OpenAI-shaped request. | |||
| 162 | + | ||||
| 163 | + | When a provider reports cache detail, zut displays cache-read hit rate as | |||
| 164 | + | `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed | |||
| 165 | + | cache miss; no percentage means the provider or historical session did not | |||
| 166 | + | report cache detail. Cache writes remain separate from that hit rate. Provider | |||
| 167 | + | retention and eviction, request routing, and OpenAI's 1,024-token eligibility | |||
| 168 | + | minimum can still produce a miss even when the stable prefix is unchanged. | |||
| 160 | 169 | ||||
| 161 | ### OpenAI Responses WebSocket mode | 170 | ### OpenAI Responses WebSocket mode | ||
| 162 | 171 | ||||
| 163 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | 172 | For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses | ||
| 164 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | 173 | the Responses API WebSocket mode for the OpenAI Responses route. It keeps one | ||
| 165 | - | connection per logical session, sends one `response.create` at a time, and | 174 | + | connection per conversation thread, sends one `response.create` at a time, |
| 166 | - | continues the most recent completed response with `previous_response_id` plus | 175 | + | and continues the most recent completed response with `previous_response_id` |
| 167 | - | only new tool or user input. The request uses `store:false`; its only | 176 | + | only when the complete response-context configuration and prior input prefix |
| 168 | - | provider-specific handshake header is `Authorization: Bearer ...`, never the | 177 | + | match exactly. Compatible continuations send only new tool or user input. |
| 178 | + | The request uses `store:false`; its only provider-specific handshake header is | |||
| 179 | + | `Authorization: Bearer ...`, never the | |||
| 169 | ChatGPT/Codex account, routing, or session headers. | 180 | ChatGPT/Codex account, routing, or session headers. | ||
| 170 | 181 | ||||
| 171 | The capability is deliberately not inherited by `--base-url`, compatible | 182 | The capability is deliberately not inherited by `--base-url`, compatible | ||
M packages/core/agent.go +7 -12
| @@ -164,8 +164,9 @@ type Agent struct { | |||
| 164 | 164 | // boundaries: before the next model call after a tool batch, or | |
| 165 | 165 | // after a text-only assistant turn finishes. It never interrupts | |
| 166 | 166 | // a running tool or cancels an in-flight provider request. | |
| 167 | - | queued []queuedMessage | |
| 168 | - | timeContext agentTimeContext | |
| 167 | + | queued []queuedMessage | |
| 168 | + | timeContext agentTimeContext | |
| 169 | + | hasSessionTimeContext bool | |
| 169 | 170 | // sessionID identifies this conversation thread. cacheSessionID identifies | |
| 170 | 171 | // the root prompt-cache affinity and may be shared by resident child | |
| 171 | 172 | // threads. Hosts bind both before the first provider request; unbound | |
| @@ -623,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 623 | 624 | } | |
| 624 | 625 | } | |
| 625 | 626 | ||
| 627 | + | a.appendDynamicContext(turnContext) | |
| 628 | + | ||
| 626 | 629 | var ( | |
| 627 | 630 | stop provider.StopReason | |
| 628 | 631 | assistantMsg provider.Message | |
| @@ -988,13 +991,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 988 | 991 | return provider.StopError, provider.Message{}, err | |
| 989 | 992 | } | |
| 990 | 993 | system, tools := a.PromptConfig() | |
| 991 | - | systemContext := a.providerTimeContext().systemText() | |
| 992 | - | if contextText := boundedTurnContext(turnContext); contextText != "" { | |
| 993 | - | if system != "" { | |
| 994 | - | system += "\n\n" | |
| 995 | - | } | |
| 996 | - | system += "[Extension context for this turn]\n" + contextText | |
| 997 | - | } | |
| 998 | 994 | // Repair pairs before projecting the copied provider-input view. The | |
| 999 | 995 | // repair can add stub results for aborted calls; those results must also | |
| 1000 | 996 | // remain in the request so every tool call still has a matching result. | |
| @@ -1006,9 +1002,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 1006 | 1002 | model: a.Model, | |
| 1007 | 1003 | } | |
| 1008 | 1004 | req := provider.Request{ | |
| 1009 | - | Model: a.Model, | |
| 1010 | - | System: system, | |
| 1011 | - | SystemContext: systemContext, | |
| 1005 | + | Model: a.Model, | |
| 1006 | + | System: system, | |
| 1012 | 1007 | // Repair any dangling tool_use blocks before sending. A turn | |
| 1013 | 1008 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 1014 | 1009 | // dev server, etc.) can leave an assistant tool_use with no | |
| @@ -164,8 +164,9 @@ type Agent struct { | |||||
| 164 | // boundaries: before the next model call after a tool batch, or | 164 | // boundaries: before the next model call after a tool batch, or | ||
| 165 | // after a text-only assistant turn finishes. It never interrupts | 165 | // after a text-only assistant turn finishes. It never interrupts | ||
| 166 | // a running tool or cancels an in-flight provider request. | 166 | // a running tool or cancels an in-flight provider request. | ||
| 167 | - | queued []queuedMessage | 167 | + | queued []queuedMessage |
| 168 | - | timeContext agentTimeContext | 168 | + | timeContext agentTimeContext |
| 169 | + | hasSessionTimeContext bool | |||
| 169 | // sessionID identifies this conversation thread. cacheSessionID identifies | 170 | // sessionID identifies this conversation thread. cacheSessionID identifies | ||
| 170 | // the root prompt-cache affinity and may be shared by resident child | 171 | // the root prompt-cache affinity and may be shared by resident child | ||
| 171 | // threads. Hosts bind both before the first provider request; unbound | 172 | // threads. Hosts bind both before the first provider request; unbound | ||
| @@ -623,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 623 | } | 624 | } | ||
| 624 | } | 625 | } | ||
| 625 | 626 | ||||
| 627 | + | a.appendDynamicContext(turnContext) | |||
| 628 | + | ||||
| 626 | var ( | 629 | var ( | ||
| 627 | stop provider.StopReason | 630 | stop provider.StopReason | ||
| 628 | assistantMsg provider.Message | 631 | assistantMsg provider.Message | ||
| @@ -988,13 +991,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 988 | return provider.StopError, provider.Message{}, err | 991 | return provider.StopError, provider.Message{}, err | ||
| 989 | } | 992 | } | ||
| 990 | system, tools := a.PromptConfig() | 993 | system, tools := a.PromptConfig() | ||
| 991 | - | systemContext := a.providerTimeContext().systemText() | |||
| 992 | - | if contextText := boundedTurnContext(turnContext); contextText != "" { | |||
| 993 | - | if system != "" { | |||
| 994 | - | system += "\n\n" | |||
| 995 | - | } | |||
| 996 | - | system += "[Extension context for this turn]\n" + contextText | |||
| 997 | - | } | |||
| 998 | // Repair pairs before projecting the copied provider-input view. The | 994 | // Repair pairs before projecting the copied provider-input view. The | ||
| 999 | // repair can add stub results for aborted calls; those results must also | 995 | // repair can add stub results for aborted calls; those results must also | ||
| 1000 | // remain in the request so every tool call still has a matching result. | 996 | // remain in the request so every tool call still has a matching result. | ||
| @@ -1006,9 +1002,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||||
| 1006 | model: a.Model, | 1002 | model: a.Model, | ||
| 1007 | } | 1003 | } | ||
| 1008 | req := provider.Request{ | 1004 | req := provider.Request{ | ||
| 1009 | - | Model: a.Model, | 1005 | + | Model: a.Model, |
| 1010 | - | System: system, | 1006 | + | System: system, |
| 1011 | - | SystemContext: systemContext, | |||
| 1012 | // Repair any dangling tool_use blocks before sending. A turn | 1007 | // Repair any dangling tool_use blocks before sending. A turn | ||
| 1013 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | 1008 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | ||
| 1014 | // dev server, etc.) can leave an assistant tool_use with no | 1009 | // dev server, etc.) can leave an assistant tool_use with no | ||
M packages/core/agent_retry_test.go +7 -9
| @@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha | |||
| 489 | 489 | return out, nil | |
| 490 | 490 | } | |
| 491 | 491 | ||
| 492 | - | func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |
| 492 | + | func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) { | |
| 493 | 493 | client := &captureClient{} | |
| 494 | 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | |
| 495 | 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |
| @@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |||
| 498 | 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 499 | 499 | t.Fatalf("Prompt returned %v", err) | |
| 500 | 500 | } | |
| 501 | - | if !strings.Contains(client.lastReq.System, "current phase: parse files") { | |
| 502 | - | t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System) | |
| 501 | + | if strings.Contains(client.lastReq.System, "current phase: parse files") { | |
| 502 | + | t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System) | |
| 503 | 503 | } | |
| 504 | - | if len(a.Messages()) != 2 { | |
| 505 | - | t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages())) | |
| 504 | + | if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") { | |
| 505 | + | t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages) | |
| 506 | 506 | } | |
| 507 | - | for _, msg := range a.Messages() { | |
| 508 | - | if strings.Contains(extractText(msg), "current phase: parse files") { | |
| 509 | - | t.Fatal("hidden turn context leaked into transcript") | |
| 510 | - | } | |
| 507 | + | if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper { | |
| 508 | + | t.Fatalf("persisted transcript = %#v, want developer context", a.Messages()) | |
| 511 | 509 | } | |
| 512 | 510 | } | |
| 513 | 511 | ||
| @@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha | |||||
| 489 | return out, nil | 489 | return out, nil | ||
| 490 | } | 490 | } | ||
| 491 | 491 | ||||
| 492 | - | func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | 492 | + | func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) { |
| 493 | client := &captureClient{} | 493 | client := &captureClient{} | ||
| 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | 494 | a := NewAgent(client, "fake-model", "system", Registry{}) | ||
| 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | 495 | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | ||
| @@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |||||
| 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | 498 | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | ||
| 499 | t.Fatalf("Prompt returned %v", err) | 499 | t.Fatalf("Prompt returned %v", err) | ||
| 500 | } | 500 | } | ||
| 501 | - | if !strings.Contains(client.lastReq.System, "current phase: parse files") { | 501 | + | if strings.Contains(client.lastReq.System, "current phase: parse files") { |
| 502 | - | t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System) | 502 | + | t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System) |
| 503 | } | 503 | } | ||
| 504 | - | if len(a.Messages()) != 2 { | 504 | + | if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") { |
| 505 | - | t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages())) | 505 | + | t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages) |
| 506 | } | 506 | } | ||
| 507 | - | for _, msg := range a.Messages() { | 507 | + | if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper { |
| 508 | - | if strings.Contains(extractText(msg), "current phase: parse files") { | 508 | + | t.Fatalf("persisted transcript = %#v, want developer context", a.Messages()) |
| 509 | - | t.Fatal("hidden turn context leaked into transcript") | |||
| 510 | - | } | |||
| 511 | } | 509 | } | ||
| 512 | } | 510 | } | ||
| 513 | 511 | ||||
M packages/core/compact.go +10 -8
| @@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 37 | 37 | msgs := append([]provider.Message(nil), a.messages...) | |
| 38 | 38 | a.mu.Unlock() | |
| 39 | 39 | ||
| 40 | + | latestContext, msgs := latestInternalContext(msgs) | |
| 40 | 41 | if len(msgs) == 0 { | |
| 41 | 42 | return "", fmt.Errorf("nothing to compact") | |
| 42 | 43 | } | |
| @@ -62,14 +63,12 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 62 | 63 | ||
| 63 | 64 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | |
| 64 | 65 | ||
| 65 | - | systemContext := a.providerTimeContext().systemText() | |
| 66 | 66 | req := provider.Request{ | |
| 67 | - | Model: a.Model, | |
| 68 | - | System: summarizationSystem, | |
| 69 | - | SystemContext: systemContext, | |
| 70 | - | MaxTokens: 4096, | |
| 71 | - | Temperature: a.Temperature, | |
| 72 | - | FastMode: fastMode, | |
| 67 | + | Model: a.Model, | |
| 68 | + | System: summarizationSystem, | |
| 69 | + | MaxTokens: 4096, | |
| 70 | + | Temperature: a.Temperature, | |
| 71 | + | FastMode: fastMode, | |
| 73 | 72 | Messages: []provider.Message{ | |
| 74 | 73 | { | |
| 75 | 74 | Role: provider.RoleUser, | |
| @@ -171,7 +170,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 171 | 170 | // a tool_use ID that doesn't exist. | |
| 172 | 171 | tail = repairOrphanedToolResults(tail) | |
| 173 | 172 | ||
| 174 | - | next := make([]provider.Message, 0, 1+len(tail)) | |
| 173 | + | next := make([]provider.Message, 0, 2+len(tail)) | |
| 174 | + | if latestContext != nil { | |
| 175 | + | next = append(next, *latestContext) | |
| 176 | + | } | |
| 175 | 177 | next = append(next, synthetic) | |
| 176 | 178 | next = append(next, tail...) | |
| 177 | 179 | ||
| @@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 37 | msgs := append([]provider.Message(nil), a.messages...) | 37 | msgs := append([]provider.Message(nil), a.messages...) | ||
| 38 | a.mu.Unlock() | 38 | a.mu.Unlock() | ||
| 39 | 39 | ||||
| 40 | + | latestContext, msgs := latestInternalContext(msgs) | |||
| 40 | if len(msgs) == 0 { | 41 | if len(msgs) == 0 { | ||
| 41 | return "", fmt.Errorf("nothing to compact") | 42 | return "", fmt.Errorf("nothing to compact") | ||
| 42 | } | 43 | } | ||
| @@ -62,14 +63,12 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 62 | 63 | ||||
| 63 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | 64 | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | ||
| 64 | 65 | ||||
| 65 | - | systemContext := a.providerTimeContext().systemText() | |||
| 66 | req := provider.Request{ | 66 | req := provider.Request{ | ||
| 67 | - | Model: a.Model, | 67 | + | Model: a.Model, |
| 68 | - | System: summarizationSystem, | 68 | + | System: summarizationSystem, |
| 69 | - | SystemContext: systemContext, | 69 | + | MaxTokens: 4096, |
| 70 | - | MaxTokens: 4096, | 70 | + | Temperature: a.Temperature, |
| 71 | - | Temperature: a.Temperature, | 71 | + | FastMode: fastMode, |
| 72 | - | FastMode: fastMode, | |||
| 73 | Messages: []provider.Message{ | 72 | Messages: []provider.Message{ | ||
| 74 | { | 73 | { | ||
| 75 | Role: provider.RoleUser, | 74 | Role: provider.RoleUser, | ||
| @@ -171,7 +170,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||||
| 171 | // a tool_use ID that doesn't exist. | 170 | // a tool_use ID that doesn't exist. | ||
| 172 | tail = repairOrphanedToolResults(tail) | 171 | tail = repairOrphanedToolResults(tail) | ||
| 173 | 172 | ||||
| 174 | - | next := make([]provider.Message, 0, 1+len(tail)) | 173 | + | next := make([]provider.Message, 0, 2+len(tail)) |
| 174 | + | if latestContext != nil { | |||
| 175 | + | next = append(next, *latestContext) | |||
| 176 | + | } | |||
| 175 | next = append(next, synthetic) | 177 | next = append(next, synthetic) | ||
| 176 | next = append(next, tail...) | 178 | next = append(next, tail...) | ||
| 177 | 179 | ||||
M packages/core/compact_lifecycle_test.go +18 -0
| @@ -53,6 +53,24 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) | |||
| 53 | 53 | return out, nil | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | + | func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) { | |
| 57 | + | client := &compactLifecycleClient{} | |
| 58 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| 59 | + | agent.SetMessages([]provider.Message{ | |
| 60 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |
| 61 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |
| 62 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |
| 63 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |
| 64 | + | }) | |
| 65 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | messages := agent.Messages() | |
| 69 | + | if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" { | |
| 70 | + | t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 56 | 74 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | |
| 57 | 75 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | |
| 58 | 76 | agent := NewAgent(client, "compact-model", "system", Registry{}) | |
| @@ -53,6 +53,24 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) | |||||
| 53 | return out, nil | 53 | return out, nil | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | + | func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) { | |||
| 57 | + | client := &compactLifecycleClient{} | |||
| 58 | + | agent := NewAgent(client, "compact-model", "system", Registry{}) | |||
| 59 | + | agent.SetMessages([]provider.Message{ | |||
| 60 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |||
| 61 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |||
| 62 | + | {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}}, | |||
| 63 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |||
| 64 | + | }) | |||
| 65 | + | if _, err := agent.Compact(context.Background(), 0, nil); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | messages := agent.Messages() | |||
| 69 | + | if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" { | |||
| 70 | + | t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 56 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | 74 | func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) { | ||
| 57 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | 75 | client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}} | ||
| 58 | agent := NewAgent(client, "compact-model", "system", Registry{}) | 76 | agent := NewAgent(client, "compact-model", "system", Registry{}) | ||
M packages/core/context_budget_test.go +1 -1
| @@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) { | |||
| 46 | 46 | if first != second { | |
| 47 | 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | |
| 48 | 48 | } | |
| 49 | - | for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | |
| 49 | + | for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | |
| 50 | 50 | if !strings.Contains(first, want) { | |
| 51 | 51 | t.Fatalf("time context %q missing %q", first, want) | |
| 52 | 52 | } | |
| @@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) { | |||||
| 46 | if first != second { | 46 | if first != second { | ||
| 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | 47 | t.Fatalf("time context changed between requests: %q != %q", first, second) | ||
| 48 | } | 48 | } | ||
| 49 | - | for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} { | 49 | + | for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} { |
| 50 | if !strings.Contains(first, want) { | 50 | if !strings.Contains(first, want) { | ||
| 51 | t.Fatalf("time context %q missing %q", first, want) | 51 | t.Fatalf("time context %q missing %q", first, want) | ||
| 52 | } | 52 | } | ||
A packages/core/dynamic_context.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const internalContextMarker = "internal_context" | |
| 11 | + | ||
| 12 | + | // appendDynamicContext persists a provider-visible, host-authored context item | |
| 13 | + | // only when its canonical value changes. It is deliberately separate from the | |
| 14 | + | // stable top-level instructions so changing time or extension state cannot | |
| 15 | + | // rewrite the instruction cache prefix. | |
| 16 | + | func (a *Agent) appendDynamicContext(turnContext string) { | |
| 17 | + | parts := make([]string, 0, 2) | |
| 18 | + | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | |
| 19 | + | parts = append(parts, timeContext) | |
| 20 | + | } | |
| 21 | + | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | |
| 22 | + | parts = append(parts, "[Extension context]\n"+turnContext) | |
| 23 | + | } | |
| 24 | + | text := strings.Join(parts, "\n\n") | |
| 25 | + | if text == "" { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | message := provider.Message{ | |
| 30 | + | Role: provider.RoleDeveloper, | |
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 32 | + | Time: time.Now(), | |
| 33 | + | Meta: map[string]string{internalContextMarker: "true"}, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | a.mu.Lock() | |
| 37 | + | for i := len(a.messages) - 1; i >= 0; i-- { | |
| 38 | + | if !isInternalContextMessage(a.messages[i]) { | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | if internalContextText(a.messages[i]) == text { | |
| 42 | + | a.mu.Unlock() | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | break | |
| 46 | + | } | |
| 47 | + | // The first dynamic item must precede the accepted user task. Later | |
| 48 | + | // snapshots extend the history at the next model boundary. | |
| 49 | + | insertAt := len(a.messages) | |
| 50 | + | if !hasInternalContextMessage(a.messages) { | |
| 51 | + | insertAt = 0 | |
| 52 | + | } | |
| 53 | + | a.messages = append(a.messages, provider.Message{}) | |
| 54 | + | copy(a.messages[insertAt+1:], a.messages[insertAt:]) | |
| 55 | + | a.messages[insertAt] = message | |
| 56 | + | a.rev++ | |
| 57 | + | a.mu.Unlock() | |
| 58 | + | a.fireMessageAppended(message) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func hasInternalContextMessage(messages []provider.Message) bool { | |
| 62 | + | for _, message := range messages { | |
| 63 | + | if isInternalContextMessage(message) { | |
| 64 | + | return true | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func isInternalContextMessage(message provider.Message) bool { | |
| 71 | + | return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true" | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) { | |
| 75 | + | filtered := make([]provider.Message, 0, len(messages)) | |
| 76 | + | var latest *provider.Message | |
| 77 | + | for _, message := range messages { | |
| 78 | + | if isInternalContextMessage(message) { | |
| 79 | + | copy := message | |
| 80 | + | latest = © | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | filtered = append(filtered, message) | |
| 84 | + | } | |
| 85 | + | return latest, filtered | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func internalContextText(message provider.Message) string { | |
| 89 | + | if len(message.Content) != 1 { | |
| 90 | + | return "" | |
| 91 | + | } | |
| 92 | + | text, _ := message.Content[0].(provider.TextBlock) | |
| 93 | + | return text.Text | |
| 94 | + | } | |
| @@ -0,0 +1,94 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const internalContextMarker = "internal_context" | |||
| 11 | + | ||||
| 12 | + | // appendDynamicContext persists a provider-visible, host-authored context item | |||
| 13 | + | // only when its canonical value changes. It is deliberately separate from the | |||
| 14 | + | // stable top-level instructions so changing time or extension state cannot | |||
| 15 | + | // rewrite the instruction cache prefix. | |||
| 16 | + | func (a *Agent) appendDynamicContext(turnContext string) { | |||
| 17 | + | parts := make([]string, 0, 2) | |||
| 18 | + | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | |||
| 19 | + | parts = append(parts, timeContext) | |||
| 20 | + | } | |||
| 21 | + | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | |||
| 22 | + | parts = append(parts, "[Extension context]\n"+turnContext) | |||
| 23 | + | } | |||
| 24 | + | text := strings.Join(parts, "\n\n") | |||
| 25 | + | if text == "" { | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | message := provider.Message{ | |||
| 30 | + | Role: provider.RoleDeveloper, | |||
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 32 | + | Time: time.Now(), | |||
| 33 | + | Meta: map[string]string{internalContextMarker: "true"}, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | a.mu.Lock() | |||
| 37 | + | for i := len(a.messages) - 1; i >= 0; i-- { | |||
| 38 | + | if !isInternalContextMessage(a.messages[i]) { | |||
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | if internalContextText(a.messages[i]) == text { | |||
| 42 | + | a.mu.Unlock() | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | break | |||
| 46 | + | } | |||
| 47 | + | // The first dynamic item must precede the accepted user task. Later | |||
| 48 | + | // snapshots extend the history at the next model boundary. | |||
| 49 | + | insertAt := len(a.messages) | |||
| 50 | + | if !hasInternalContextMessage(a.messages) { | |||
| 51 | + | insertAt = 0 | |||
| 52 | + | } | |||
| 53 | + | a.messages = append(a.messages, provider.Message{}) | |||
| 54 | + | copy(a.messages[insertAt+1:], a.messages[insertAt:]) | |||
| 55 | + | a.messages[insertAt] = message | |||
| 56 | + | a.rev++ | |||
| 57 | + | a.mu.Unlock() | |||
| 58 | + | a.fireMessageAppended(message) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func hasInternalContextMessage(messages []provider.Message) bool { | |||
| 62 | + | for _, message := range messages { | |||
| 63 | + | if isInternalContextMessage(message) { | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func isInternalContextMessage(message provider.Message) bool { | |||
| 71 | + | return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true" | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) { | |||
| 75 | + | filtered := make([]provider.Message, 0, len(messages)) | |||
| 76 | + | var latest *provider.Message | |||
| 77 | + | for _, message := range messages { | |||
| 78 | + | if isInternalContextMessage(message) { | |||
| 79 | + | copy := message | |||
| 80 | + | latest = © | |||
| 81 | + | continue | |||
| 82 | + | } | |||
| 83 | + | filtered = append(filtered, message) | |||
| 84 | + | } | |||
| 85 | + | return latest, filtered | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func internalContextText(message provider.Message) string { | |||
| 89 | + | if len(message.Content) != 1 { | |||
| 90 | + | return "" | |||
| 91 | + | } | |||
| 92 | + | text, _ := message.Content[0].(provider.TextBlock) | |||
| 93 | + | return text.Text | |||
| 94 | + | } | |||
M packages/core/time_context.go +12 -1
| @@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string | |||
| 65 | 65 | zone: timezone, | |
| 66 | 66 | offset: offset, | |
| 67 | 67 | } | |
| 68 | + | a.hasSessionTimeContext = true | |
| 68 | 69 | a.mu.Unlock() | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | func (a *Agent) providerTimeContext() agentTimeContext { | |
| 72 | 73 | a.mu.Lock() | |
| 73 | 74 | defer a.mu.Unlock() | |
| 75 | + | if !a.hasSessionTimeContext { | |
| 76 | + | return agentTimeContext{} | |
| 77 | + | } | |
| 74 | 78 | return a.timeContext | |
| 75 | 79 | } | |
| 76 | 80 | ||
| 77 | 81 | func (c agentTimeContext) systemText() string { | |
| 82 | + | return c.developerText() | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // developerText returns host context projected after stable instructions. | |
| 86 | + | // Minute precision is sufficient for coordination and avoids generating a | |
| 87 | + | // unique timestamp solely to distinguish otherwise-equal agents. | |
| 88 | + | func (c agentTimeContext) developerText() string { | |
| 78 | 89 | if c.started.IsZero() { | |
| 79 | 90 | return "" | |
| 80 | 91 | } | |
| @@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string { | |||
| 91 | 102 | _, seconds := c.started.In(location).Zone() | |
| 92 | 103 | offset = formatUTCOffset(seconds) | |
| 93 | 104 | } | |
| 94 | - | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset) | |
| 105 | + | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset) | |
| 95 | 106 | } | |
| 96 | 107 | ||
| 97 | 108 | func formatUTCOffset(seconds int) string { | |
| @@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string | |||||
| 65 | zone: timezone, | 65 | zone: timezone, | ||
| 66 | offset: offset, | 66 | offset: offset, | ||
| 67 | } | 67 | } | ||
| 68 | + | a.hasSessionTimeContext = true | |||
| 68 | a.mu.Unlock() | 69 | a.mu.Unlock() | ||
| 69 | } | 70 | } | ||
| 70 | 71 | ||||
| 71 | func (a *Agent) providerTimeContext() agentTimeContext { | 72 | func (a *Agent) providerTimeContext() agentTimeContext { | ||
| 72 | a.mu.Lock() | 73 | a.mu.Lock() | ||
| 73 | defer a.mu.Unlock() | 74 | defer a.mu.Unlock() | ||
| 75 | + | if !a.hasSessionTimeContext { | |||
| 76 | + | return agentTimeContext{} | |||
| 77 | + | } | |||
| 74 | return a.timeContext | 78 | return a.timeContext | ||
| 75 | } | 79 | } | ||
| 76 | 80 | ||||
| 77 | func (c agentTimeContext) systemText() string { | 81 | func (c agentTimeContext) systemText() string { | ||
| 82 | + | return c.developerText() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // developerText returns host context projected after stable instructions. | |||
| 86 | + | // Minute precision is sufficient for coordination and avoids generating a | |||
| 87 | + | // unique timestamp solely to distinguish otherwise-equal agents. | |||
| 88 | + | func (c agentTimeContext) developerText() string { | |||
| 78 | if c.started.IsZero() { | 89 | if c.started.IsZero() { | ||
| 79 | return "" | 90 | return "" | ||
| 80 | } | 91 | } | ||
| @@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string { | |||||
| 91 | _, seconds := c.started.In(location).Zone() | 102 | _, seconds := c.started.In(location).Zone() | ||
| 92 | offset = formatUTCOffset(seconds) | 103 | offset = formatUTCOffset(seconds) | ||
| 93 | } | 104 | } | ||
| 94 | - | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset) | 105 | + | return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset) |
| 95 | } | 106 | } | ||
| 96 | 107 | ||||
| 97 | func formatUTCOffset(seconds int) string { | 108 | func formatUTCOffset(seconds int) string { | ||
M packages/provider/anthropic.go +1 -1
| @@ -357,7 +357,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 357 | 357 | for _, msg := range req.Messages { | |
| 358 | 358 | renameTools := c.oauthTok != "" | |
| 359 | 359 | switch msg.Role { | |
| 360 | - | case RoleUser: | |
| 360 | + | case RoleDeveloper, RoleUser: | |
| 361 | 361 | out.Messages = append(out.Messages, anthMessage{ | |
| 362 | 362 | Role: "user", | |
| 363 | 363 | Content: convertAnthContent(msg.Content, renameTools), | |
| @@ -357,7 +357,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 357 | for _, msg := range req.Messages { | 357 | for _, msg := range req.Messages { | ||
| 358 | renameTools := c.oauthTok != "" | 358 | renameTools := c.oauthTok != "" | ||
| 359 | switch msg.Role { | 359 | switch msg.Role { | ||
| 360 | - | case RoleUser: | 360 | + | case RoleDeveloper, RoleUser: |
| 361 | out.Messages = append(out.Messages, anthMessage{ | 361 | out.Messages = append(out.Messages, anthMessage{ | ||
| 362 | Role: "user", | 362 | Role: "user", | ||
| 363 | Content: convertAnthContent(msg.Content, renameTools), | 363 | Content: convertAnthContent(msg.Content, renameTools), | ||
M packages/provider/gemini.go +1 -1
| @@ -196,7 +196,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 196 | 196 | } | |
| 197 | 197 | for _, msg := range msgs { | |
| 198 | 198 | switch msg.Role { | |
| 199 | - | case RoleUser: | |
| 199 | + | case RoleDeveloper, RoleUser: | |
| 200 | 200 | parts := convertGemUserParts(msg.Content) | |
| 201 | 201 | if len(parts) == 0 { | |
| 202 | 202 | continue | |
| @@ -196,7 +196,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 196 | } | 196 | } | ||
| 197 | for _, msg := range msgs { | 197 | for _, msg := range msgs { | ||
| 198 | switch msg.Role { | 198 | switch msg.Role { | ||
| 199 | - | case RoleUser: | 199 | + | case RoleDeveloper, RoleUser: |
| 200 | parts := convertGemUserParts(msg.Content) | 200 | parts := convertGemUserParts(msg.Content) | ||
| 201 | if len(parts) == 0 { | 201 | if len(parts) == 0 { | ||
| 202 | continue | 202 | continue | ||
M packages/provider/openai.go +3 -0
| @@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 299 | 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 300 | 300 | for _, msg := range req.Messages { | |
| 301 | 301 | switch msg.Role { | |
| 302 | + | case RoleDeveloper: | |
| 303 | + | content := buildOAIUserContent(msg.Content, textOnly) | |
| 304 | + | out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content}) | |
| 302 | 305 | case RoleUser: | |
| 303 | 306 | content := buildOAIUserContent(msg.Content, textOnly) | |
| 304 | 307 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | |
| @@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | 299 | req.Messages = RepairOrphanedToolResults(req.Messages) | ||
| 300 | for _, msg := range req.Messages { | 300 | for _, msg := range req.Messages { | ||
| 301 | switch msg.Role { | 301 | switch msg.Role { | ||
| 302 | + | case RoleDeveloper: | |||
| 303 | + | content := buildOAIUserContent(msg.Content, textOnly) | |||
| 304 | + | out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content}) | |||
| 302 | case RoleUser: | 305 | case RoleUser: | ||
| 303 | content := buildOAIUserContent(msg.Content, textOnly) | 306 | content := buildOAIUserContent(msg.Content, textOnly) | ||
| 304 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | 307 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | ||
M packages/provider/openai_codex.go +51 -22
| @@ -51,10 +51,11 @@ type codexClient struct { | |||
| 51 | 51 | // responsesCapabilities records extensions verified for the exact provider | |
| 52 | 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | |
| 53 | 53 | type responsesCapabilities struct { | |
| 54 | - | StablePromptCacheKey bool | |
| 55 | - | SessionHeader bool | |
| 56 | - | ThreadHeader bool | |
| 57 | - | ClientRequestHeader bool | |
| 54 | + | StablePromptCacheKey bool | |
| 55 | + | ExplicitPromptCacheBreakpoint bool | |
| 56 | + | SessionHeader bool | |
| 57 | + | ThreadHeader bool | |
| 58 | + | ClientRequestHeader bool | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | |
| @@ -108,10 +109,19 @@ type codexOutputText struct { | |||
| 108 | 109 | Annotations []any `json:"annotations"` | |
| 109 | 110 | } | |
| 110 | 111 | ||
| 112 | + | type codexPromptCacheOptions struct { | |
| 113 | + | Mode string `json:"mode"` | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | type codexPromptCacheBreakpoint struct { | |
| 117 | + | Type string `json:"type"` | |
| 118 | + | } | |
| 119 | + | ||
| 111 | 120 | type codexInputMessage struct { | |
| 112 | - | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 113 | - | Role string `json:"role"` | |
| 114 | - | Content []any `json:"content"` | |
| 121 | + | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 122 | + | Role string `json:"role"` | |
| 123 | + | Content []any `json:"content"` | |
| 124 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |
| 115 | 125 | } | |
| 116 | 126 | ||
| 117 | 127 | type codexOutputMessage struct { | |
| @@ -166,19 +176,20 @@ type codexReasoningConfig struct { | |||
| 166 | 176 | } | |
| 167 | 177 | ||
| 168 | 178 | type codexRequest struct { | |
| 169 | - | Model string `json:"model"` | |
| 170 | - | Store bool `json:"store"` | |
| 171 | - | Stream bool `json:"stream,omitempty"` | |
| 172 | - | Instructions string `json:"instructions,omitempty"` | |
| 173 | - | Input []any `json:"input"` | |
| 174 | - | Tools []codexTool `json:"tools,omitempty"` | |
| 175 | - | ToolChoice string `json:"tool_choice,omitempty"` | |
| 176 | - | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 177 | - | Include []string `json:"include,omitempty"` | |
| 178 | - | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 179 | - | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 180 | - | ServiceTier string `json:"service_tier,omitempty"` | |
| 181 | - | PreviousResponseID string `json:"previous_response_id,omitempty"` | |
| 179 | + | Model string `json:"model"` | |
| 180 | + | Store bool `json:"store"` | |
| 181 | + | Stream bool `json:"stream,omitempty"` | |
| 182 | + | Instructions string `json:"instructions,omitempty"` | |
| 183 | + | Input []any `json:"input"` | |
| 184 | + | Tools []codexTool `json:"tools,omitempty"` | |
| 185 | + | ToolChoice string `json:"tool_choice,omitempty"` | |
| 186 | + | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 187 | + | Include []string `json:"include,omitempty"` | |
| 188 | + | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 189 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 190 | + | PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"` | |
| 191 | + | ServiceTier string `json:"service_tier,omitempty"` | |
| 192 | + | PreviousResponseID string `json:"previous_response_id,omitempty"` | |
| 182 | 193 | } | |
| 183 | 194 | ||
| 184 | 195 | // ---- Request building ---- | |
| @@ -195,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||
| 195 | 206 | return FindModel("openai", id) | |
| 196 | 207 | } | |
| 197 | 208 | ||
| 209 | + | func isGPT56Model(model string) bool { | |
| 210 | + | return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-") | |
| 211 | + | } | |
| 212 | + | ||
| 198 | 213 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 199 | 214 | providerName := c.providerName | |
| 200 | 215 | if providerName == "" { | |
| @@ -220,6 +235,16 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 220 | 235 | if req.FastMode { | |
| 221 | 236 | body.ServiceTier = fastModeServiceTier | |
| 222 | 237 | } | |
| 238 | + | if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) { | |
| 239 | + | // Keep implicit mode so OpenAI can retain later conversation | |
| 240 | + | // checkpoints as well as this stable instruction/tool boundary. | |
| 241 | + | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | |
| 242 | + | body.Input = append(body.Input, codexInputMessage{ | |
| 243 | + | Role: "developer", | |
| 244 | + | Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}}, | |
| 245 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"}, | |
| 246 | + | }) | |
| 247 | + | } | |
| 223 | 248 | if m.Reasoning { | |
| 224 | 249 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | |
| 225 | 250 | if hasReasoningLevelOverride(m, req.Reasoning) { | |
| @@ -250,7 +275,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 250 | 275 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 251 | 276 | for _, msg := range req.Messages { | |
| 252 | 277 | switch msg.Role { | |
| 253 | - | case RoleUser: | |
| 278 | + | case RoleDeveloper, RoleUser: | |
| 254 | 279 | content := []any{} | |
| 255 | 280 | for _, c := range msg.Content { | |
| 256 | 281 | switch v := c.(type) { | |
| @@ -266,7 +291,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 266 | 291 | if len(content) == 0 { | |
| 267 | 292 | continue | |
| 268 | 293 | } | |
| 269 | - | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | |
| 294 | + | role := "user" | |
| 295 | + | if msg.Role == RoleDeveloper { | |
| 296 | + | role = "developer" | |
| 297 | + | } | |
| 298 | + | body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) | |
| 270 | 299 | case RoleAssistant: | |
| 271 | 300 | // Emit one output_message per text block, one function_call per | |
| 272 | 301 | // tool call, and one reasoning item per ReasoningBlock, | |
| @@ -51,10 +51,11 @@ type codexClient struct { | |||||
| 51 | // responsesCapabilities records extensions verified for the exact provider | 51 | // responsesCapabilities records extensions verified for the exact provider | ||
| 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | 52 | // client, rather than assuming every Responses-shaped endpoint accepts them. | ||
| 53 | type responsesCapabilities struct { | 53 | type responsesCapabilities struct { | ||
| 54 | - | StablePromptCacheKey bool | 54 | + | StablePromptCacheKey bool |
| 55 | - | SessionHeader bool | 55 | + | ExplicitPromptCacheBreakpoint bool |
| 56 | - | ThreadHeader bool | 56 | + | SessionHeader bool |
| 57 | - | ClientRequestHeader bool | 57 | + | ThreadHeader bool |
| 58 | + | ClientRequestHeader bool | |||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | 61 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | ||
| @@ -108,10 +109,19 @@ type codexOutputText struct { | |||||
| 108 | Annotations []any `json:"annotations"` | 109 | Annotations []any `json:"annotations"` | ||
| 109 | } | 110 | } | ||
| 110 | 111 | ||||
| 112 | + | type codexPromptCacheOptions struct { | |||
| 113 | + | Mode string `json:"mode"` | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | type codexPromptCacheBreakpoint struct { | |||
| 117 | + | Type string `json:"type"` | |||
| 118 | + | } | |||
| 119 | + | ||||
| 111 | type codexInputMessage struct { | 120 | type codexInputMessage struct { | ||
| 112 | - | Type string `json:"type,omitempty"` // "message" (optional for input) | 121 | + | Type string `json:"type,omitempty"` // "message" (optional for input) |
| 113 | - | Role string `json:"role"` | 122 | + | Role string `json:"role"` |
| 114 | - | Content []any `json:"content"` | 123 | + | Content []any `json:"content"` |
| 124 | + | PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"` | |||
| 115 | } | 125 | } | ||
| 116 | 126 | ||||
| 117 | type codexOutputMessage struct { | 127 | type codexOutputMessage struct { | ||
| @@ -166,19 +176,20 @@ type codexReasoningConfig struct { | |||||
| 166 | } | 176 | } | ||
| 167 | 177 | ||||
| 168 | type codexRequest struct { | 178 | type codexRequest struct { | ||
| 169 | - | Model string `json:"model"` | 179 | + | Model string `json:"model"` |
| 170 | - | Store bool `json:"store"` | 180 | + | Store bool `json:"store"` |
| 171 | - | Stream bool `json:"stream,omitempty"` | 181 | + | Stream bool `json:"stream,omitempty"` |
| 172 | - | Instructions string `json:"instructions,omitempty"` | 182 | + | Instructions string `json:"instructions,omitempty"` |
| 173 | - | Input []any `json:"input"` | 183 | + | Input []any `json:"input"` |
| 174 | - | Tools []codexTool `json:"tools,omitempty"` | 184 | + | Tools []codexTool `json:"tools,omitempty"` |
| 175 | - | ToolChoice string `json:"tool_choice,omitempty"` | 185 | + | ToolChoice string `json:"tool_choice,omitempty"` |
| 176 | - | ParallelToolCalls bool `json:"parallel_tool_calls"` | 186 | + | ParallelToolCalls bool `json:"parallel_tool_calls"` |
| 177 | - | Include []string `json:"include,omitempty"` | 187 | + | Include []string `json:"include,omitempty"` |
| 178 | - | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | 188 | + | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` |
| 179 | - | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | 189 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` |
| 180 | - | ServiceTier string `json:"service_tier,omitempty"` | 190 | + | PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"` |
| 181 | - | PreviousResponseID string `json:"previous_response_id,omitempty"` | 191 | + | ServiceTier string `json:"service_tier,omitempty"` |
| 192 | + | PreviousResponseID string `json:"previous_response_id,omitempty"` | |||
| 182 | } | 193 | } | ||
| 183 | 194 | ||||
| 184 | // ---- Request building ---- | 195 | // ---- Request building ---- | ||
| @@ -195,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||||
| 195 | return FindModel("openai", id) | 206 | return FindModel("openai", id) | ||
| 196 | } | 207 | } | ||
| 197 | 208 | ||||
| 209 | + | func isGPT56Model(model string) bool { | |||
| 210 | + | return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 198 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | 213 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | ||
| 199 | providerName := c.providerName | 214 | providerName := c.providerName | ||
| 200 | if providerName == "" { | 215 | if providerName == "" { | ||
| @@ -220,6 +235,16 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 220 | if req.FastMode { | 235 | if req.FastMode { | ||
| 221 | body.ServiceTier = fastModeServiceTier | 236 | body.ServiceTier = fastModeServiceTier | ||
| 222 | } | 237 | } | ||
| 238 | + | if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) { | |||
| 239 | + | // Keep implicit mode so OpenAI can retain later conversation | |||
| 240 | + | // checkpoints as well as this stable instruction/tool boundary. | |||
| 241 | + | body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"} | |||
| 242 | + | body.Input = append(body.Input, codexInputMessage{ | |||
| 243 | + | Role: "developer", | |||
| 244 | + | Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}}, | |||
| 245 | + | PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"}, | |||
| 246 | + | }) | |||
| 247 | + | } | |||
| 223 | if m.Reasoning { | 248 | if m.Reasoning { | ||
| 224 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | 249 | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | ||
| 225 | if hasReasoningLevelOverride(m, req.Reasoning) { | 250 | if hasReasoningLevelOverride(m, req.Reasoning) { | ||
| @@ -250,7 +275,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 250 | req.Messages = RepairOrphanedToolResults(req.Messages) | 275 | req.Messages = RepairOrphanedToolResults(req.Messages) | ||
| 251 | for _, msg := range req.Messages { | 276 | for _, msg := range req.Messages { | ||
| 252 | switch msg.Role { | 277 | switch msg.Role { | ||
| 253 | - | case RoleUser: | 278 | + | case RoleDeveloper, RoleUser: |
| 254 | content := []any{} | 279 | content := []any{} | ||
| 255 | for _, c := range msg.Content { | 280 | for _, c := range msg.Content { | ||
| 256 | switch v := c.(type) { | 281 | switch v := c.(type) { | ||
| @@ -266,7 +291,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 266 | if len(content) == 0 { | 291 | if len(content) == 0 { | ||
| 267 | continue | 292 | continue | ||
| 268 | } | 293 | } | ||
| 269 | - | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | 294 | + | role := "user" |
| 295 | + | if msg.Role == RoleDeveloper { | |||
| 296 | + | role = "developer" | |||
| 297 | + | } | |||
| 298 | + | body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) | |||
| 270 | case RoleAssistant: | 299 | case RoleAssistant: | ||
| 271 | // Emit one output_message per text block, one function_call per | 300 | // Emit one output_message per text block, one function_call per | ||
| 272 | // tool call, and one reasoning item per ReasoningBlock, | 301 | // tool call, and one reasoning item per ReasoningBlock, | ||
M packages/provider/openai_codex_test.go +11 -0
| @@ -205,6 +205,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 205 | 205 | if gotBody.PromptCacheKey != "cache-root-2" { | |
| 206 | 206 | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) | |
| 207 | 207 | } | |
| 208 | + | if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 { | |
| 209 | + | t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input) | |
| 210 | + | } | |
| 211 | + | boundary, ok := gotBody.Input[0].(map[string]any) | |
| 212 | + | if !ok || boundary["role"] != "developer" { | |
| 213 | + | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | |
| 214 | + | } | |
| 215 | + | breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any) | |
| 216 | + | if !ok || breakpoint["type"] != "stable" { | |
| 217 | + | t.Fatalf("stable cache breakpoint = %#v", boundary) | |
| 218 | + | } | |
| 208 | 219 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | |
| 209 | 220 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | |
| 210 | 221 | } | |
| @@ -205,6 +205,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||||
| 205 | if gotBody.PromptCacheKey != "cache-root-2" { | 205 | if gotBody.PromptCacheKey != "cache-root-2" { | ||
| 206 | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) | 206 | t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) | ||
| 207 | } | 207 | } | ||
| 208 | + | if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 { | |||
| 209 | + | t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input) | |||
| 210 | + | } | |||
| 211 | + | boundary, ok := gotBody.Input[0].(map[string]any) | |||
| 212 | + | if !ok || boundary["role"] != "developer" { | |||
| 213 | + | t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) | |||
| 214 | + | } | |||
| 215 | + | breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any) | |||
| 216 | + | if !ok || breakpoint["type"] != "stable" { | |||
| 217 | + | t.Fatalf("stable cache breakpoint = %#v", boundary) | |||
| 218 | + | } | |||
| 208 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | 219 | if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { | ||
| 209 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | 220 | t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) | ||
| 210 | } | 221 | } | ||
M packages/provider/openai_responses.go +3 -2
| @@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||
| 78 | 78 | errorLabel: "openai", | |
| 79 | 79 | providerName: name, | |
| 80 | 80 | capabilities: responsesCapabilities{ | |
| 81 | - | // Only the official OpenAI provider declares this extension. | |
| 81 | + | // Only the official OpenAI provider declares these extensions. | |
| 82 | 82 | // A Responses-shaped custom endpoint must opt in through its | |
| 83 | 83 | // own client rather than inheriting OpenAI's cache contract. | |
| 84 | - | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 84 | + | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 85 | + | ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL), | |
| 85 | 86 | }, | |
| 86 | 87 | http: httpClient, | |
| 87 | 88 | } | |
| @@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||||
| 78 | errorLabel: "openai", | 78 | errorLabel: "openai", | ||
| 79 | providerName: name, | 79 | providerName: name, | ||
| 80 | capabilities: responsesCapabilities{ | 80 | capabilities: responsesCapabilities{ | ||
| 81 | - | // Only the official OpenAI provider declares this extension. | 81 | + | // Only the official OpenAI provider declares these extensions. |
| 82 | // A Responses-shaped custom endpoint must opt in through its | 82 | // A Responses-shaped custom endpoint must opt in through its | ||
| 83 | // own client rather than inheriting OpenAI's cache contract. | 83 | // own client rather than inheriting OpenAI's cache contract. | ||
| 84 | - | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), | 84 | + | StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL), |
| 85 | + | ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL), | |||
| 85 | }, | 86 | }, | ||
| 86 | http: httpClient, | 87 | http: httpClient, | ||
| 87 | } | 88 | } | ||
M packages/provider/openai_responses_websocket.go +23 -8
| @@ -36,7 +36,9 @@ type responsesWebSocketSession struct { | |||
| 36 | 36 | raw chan sseEvent | |
| 37 | 37 | readerDone chan struct{} | |
| 38 | 38 | ||
| 39 | - | lastRequest []Message | |
| 39 | + | // lastRequest is the complete request accepted by the server. Continuation | |
| 40 | + | // must compare every response-context setting, not merely its transcript. | |
| 41 | + | lastRequest Request | |
| 40 | 42 | lastResponseID string | |
| 41 | 43 | } | |
| 42 | 44 | ||
| @@ -245,7 +247,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||
| 245 | 247 | terminal = true | |
| 246 | 248 | stateMu.Unlock() | |
| 247 | 249 | completed = true | |
| 248 | - | session.recordCompleted(req.Messages, responseID) | |
| 250 | + | session.recordCompleted(req, responseID) | |
| 249 | 251 | }) | |
| 250 | 252 | close(finished) | |
| 251 | 253 | if !completed { | |
| @@ -421,18 +423,29 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req | |||
| 421 | 423 | func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) { | |
| 422 | 424 | s.mu.Lock() | |
| 423 | 425 | defer s.mu.Unlock() | |
| 424 | - | if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) { | |
| 426 | + | previous := s.lastRequest | |
| 427 | + | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | |
| 425 | 428 | return req, "" | |
| 426 | 429 | } | |
| 427 | 430 | // The cached response already contains the assistant output from the last | |
| 428 | - | // generation. Send only the new tool results and/or user message. | |
| 429 | - | req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...) | |
| 431 | + | // generation. Send only the strict new tool-result and/or user suffix. | |
| 432 | + | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | |
| 430 | 433 | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | |
| 431 | 434 | req.Messages = req.Messages[1:] | |
| 432 | 435 | } | |
| 433 | 436 | return req, s.lastResponseID | |
| 434 | 437 | } | |
| 435 | 438 | ||
| 439 | + | func sameResponsesContinuationConfig(previous, current Request) bool { | |
| 440 | + | previous.Messages = nil | |
| 441 | + | previous.Context.TurnID = "" | |
| 442 | + | previous.Lifecycle = nil | |
| 443 | + | current.Messages = nil | |
| 444 | + | current.Context.TurnID = "" | |
| 445 | + | current.Lifecycle = nil | |
| 446 | + | return reflect.DeepEqual(previous, current) | |
| 447 | + | } | |
| 448 | + | ||
| 436 | 449 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | |
| 437 | 450 | s.mu.Lock() | |
| 438 | 451 | if s.conn != nil && s.raw != nil { | |
| @@ -461,10 +474,12 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||
| 461 | 474 | return s.conn, s.raw | |
| 462 | 475 | } | |
| 463 | 476 | ||
| 464 | - | func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) { | |
| 477 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) { | |
| 465 | 478 | s.mu.Lock() | |
| 466 | 479 | defer s.mu.Unlock() | |
| 467 | - | s.lastRequest = append(s.lastRequest[:0], messages...) | |
| 480 | + | request.Messages = append([]Message(nil), request.Messages...) | |
| 481 | + | request.Lifecycle = nil | |
| 482 | + | s.lastRequest = request | |
| 468 | 483 | s.lastResponseID = responseID | |
| 469 | 484 | } | |
| 470 | 485 | ||
| @@ -493,7 +508,7 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||
| 493 | 508 | s.conn = nil | |
| 494 | 509 | s.raw = nil | |
| 495 | 510 | s.readerDone = nil | |
| 496 | - | s.lastRequest = nil | |
| 511 | + | s.lastRequest = Request{} | |
| 497 | 512 | s.lastResponseID = "" | |
| 498 | 513 | } | |
| 499 | 514 | ||
| @@ -36,7 +36,9 @@ type responsesWebSocketSession struct { | |||||
| 36 | raw chan sseEvent | 36 | raw chan sseEvent | ||
| 37 | readerDone chan struct{} | 37 | readerDone chan struct{} | ||
| 38 | 38 | ||||
| 39 | - | lastRequest []Message | 39 | + | // lastRequest is the complete request accepted by the server. Continuation |
| 40 | + | // must compare every response-context setting, not merely its transcript. | |||
| 41 | + | lastRequest Request | |||
| 40 | lastResponseID string | 42 | lastResponseID string | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| @@ -245,7 +247,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c | |||||
| 245 | terminal = true | 247 | terminal = true | ||
| 246 | stateMu.Unlock() | 248 | stateMu.Unlock() | ||
| 247 | completed = true | 249 | completed = true | ||
| 248 | - | session.recordCompleted(req.Messages, responseID) | 250 | + | session.recordCompleted(req, responseID) |
| 249 | }) | 251 | }) | ||
| 250 | close(finished) | 252 | close(finished) | ||
| 251 | if !completed { | 253 | if !completed { | ||
| @@ -421,18 +423,29 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req | |||||
| 421 | func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) { | 423 | func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) { | ||
| 422 | s.mu.Lock() | 424 | s.mu.Lock() | ||
| 423 | defer s.mu.Unlock() | 425 | defer s.mu.Unlock() | ||
| 424 | - | if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) { | 426 | + | previous := s.lastRequest |
| 427 | + | if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) { | |||
| 425 | return req, "" | 428 | return req, "" | ||
| 426 | } | 429 | } | ||
| 427 | // The cached response already contains the assistant output from the last | 430 | // The cached response already contains the assistant output from the last | ||
| 428 | - | // generation. Send only the new tool results and/or user message. | 431 | + | // generation. Send only the strict new tool-result and/or user suffix. |
| 429 | - | req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...) | 432 | + | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) |
| 430 | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | 433 | for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant { | ||
| 431 | req.Messages = req.Messages[1:] | 434 | req.Messages = req.Messages[1:] | ||
| 432 | } | 435 | } | ||
| 433 | return req, s.lastResponseID | 436 | return req, s.lastResponseID | ||
| 434 | } | 437 | } | ||
| 435 | 438 | ||||
| 439 | + | func sameResponsesContinuationConfig(previous, current Request) bool { | |||
| 440 | + | previous.Messages = nil | |||
| 441 | + | previous.Context.TurnID = "" | |||
| 442 | + | previous.Lifecycle = nil | |||
| 443 | + | current.Messages = nil | |||
| 444 | + | current.Context.TurnID = "" | |||
| 445 | + | current.Lifecycle = nil | |||
| 446 | + | return reflect.DeepEqual(previous, current) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 436 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | 449 | func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error { | ||
| 437 | s.mu.Lock() | 450 | s.mu.Lock() | ||
| 438 | if s.conn != nil && s.raw != nil { | 451 | if s.conn != nil && s.raw != nil { | ||
| @@ -461,10 +474,12 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve | |||||
| 461 | return s.conn, s.raw | 474 | return s.conn, s.raw | ||
| 462 | } | 475 | } | ||
| 463 | 476 | ||||
| 464 | - | func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) { | 477 | + | func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) { |
| 465 | s.mu.Lock() | 478 | s.mu.Lock() | ||
| 466 | defer s.mu.Unlock() | 479 | defer s.mu.Unlock() | ||
| 467 | - | s.lastRequest = append(s.lastRequest[:0], messages...) | 480 | + | request.Messages = append([]Message(nil), request.Messages...) |
| 481 | + | request.Lifecycle = nil | |||
| 482 | + | s.lastRequest = request | |||
| 468 | s.lastResponseID = responseID | 483 | s.lastResponseID = responseID | ||
| 469 | } | 484 | } | ||
| 470 | 485 | ||||
| @@ -493,7 +508,7 @@ func (s *responsesWebSocketSession) invalidateLocked() { | |||||
| 493 | s.conn = nil | 508 | s.conn = nil | ||
| 494 | s.raw = nil | 509 | s.raw = nil | ||
| 495 | s.readerDone = nil | 510 | s.readerDone = nil | ||
| 496 | - | s.lastRequest = nil | 511 | + | s.lastRequest = Request{} |
| 497 | s.lastResponseID = "" | 512 | s.lastResponseID = "" | ||
| 498 | } | 513 | } | ||
| 499 | 514 | ||||
M packages/provider/openai_responses_websocket_test.go +3 -2
| @@ -169,8 +169,9 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||
| 169 | 169 | } | |
| 170 | 170 | assertCompletedResponse(t, client, first) | |
| 171 | 171 | second := Request{ | |
| 172 | - | Model: "gpt-5.6-sol", | |
| 173 | - | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 172 | + | Model: "gpt-5.6-sol", | |
| 173 | + | FastMode: true, | |
| 174 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |
| 174 | 175 | Messages: append(append([]Message(nil), first.Messages...), | |
| 175 | 176 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | |
| 176 | 177 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | |
| @@ -169,8 +169,9 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) { | |||||
| 169 | } | 169 | } | ||
| 170 | assertCompletedResponse(t, client, first) | 170 | assertCompletedResponse(t, client, first) | ||
| 171 | second := Request{ | 171 | second := Request{ | ||
| 172 | - | Model: "gpt-5.6-sol", | 172 | + | Model: "gpt-5.6-sol", |
| 173 | - | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | 173 | + | FastMode: true, |
| 174 | + | Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"}, | |||
| 174 | Messages: append(append([]Message(nil), first.Messages...), | 175 | Messages: append(append([]Message(nil), first.Messages...), | ||
| 175 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | 176 | Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, | ||
| 176 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | 177 | Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}}, | ||
M packages/provider/provider.go +3 -0
| @@ -20,6 +20,9 @@ const ( | |||
| 20 | 20 | RoleUser Role = "user" | |
| 21 | 21 | RoleAssistant Role = "assistant" | |
| 22 | 22 | RoleTool Role = "tool" | |
| 23 | + | // RoleDeveloper is host-authored provider context. Core persists it for | |
| 24 | + | // replay but presentation layers must not render it as ordinary chat. | |
| 25 | + | RoleDeveloper Role = "developer" | |
| 23 | 26 | ) | |
| 24 | 27 | ||
| 25 | 28 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | |
| @@ -20,6 +20,9 @@ const ( | |||||
| 20 | RoleUser Role = "user" | 20 | RoleUser Role = "user" | ||
| 21 | RoleAssistant Role = "assistant" | 21 | RoleAssistant Role = "assistant" | ||
| 22 | RoleTool Role = "tool" | 22 | RoleTool Role = "tool" | ||
| 23 | + | // RoleDeveloper is host-authored provider context. Core persists it for | |||
| 24 | + | // replay but presentation layers must not render it as ordinary chat. | |||
| 25 | + | RoleDeveloper Role = "developer" | |||
| 23 | ) | 26 | ) | ||
| 24 | 27 | ||||
| 25 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | 28 | // Content is a block inside a Message. One of TextBlock, ImageBlock, | ||