Compare commits
1 commit
dev
...
reasoning-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6208a8c50 |
16 changed files with 169 additions and 22 deletions
|
|
@ -132,7 +132,7 @@ _Avoid_: Response envelope
|
||||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||||
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata. Visible reasoning remains canonical until target-protocol lowering: OpenAI Chat-compatible targets replay it as `reasoning_content`, while protocols that require signed native reasoning lower unsigned reasoning to ordinary assistant text.
|
||||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||||
|
|
|
||||||
|
|
@ -74,17 +74,13 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||||
const content = message.content.flatMap((item): ContentPart[] => {
|
const content = message.content.flatMap((item): ContentPart[] => {
|
||||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||||
if (item.type === "reasoning")
|
if (item.type === "reasoning")
|
||||||
return sameModel
|
return [
|
||||||
? [
|
{
|
||||||
{
|
type: "reasoning",
|
||||||
type: "reasoning",
|
text: item.text,
|
||||||
text: item.text,
|
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
||||||
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
},
|
||||||
},
|
]
|
||||||
]
|
|
||||||
: item.text.length > 0
|
|
||||||
? [{ type: "text", text: item.text }]
|
|
||||||
: []
|
|
||||||
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
||||||
if (item.provider?.executed !== true) return [call]
|
if (item.provider?.executed !== true) return [call]
|
||||||
const result = toolResult(
|
const result = toolResult(
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ Recent work
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(messages[0]?.content).toEqual([
|
expect(messages[0]?.content).toEqual([
|
||||||
{ type: "text", text: "Visible thought" },
|
{ type: "reasoning", text: "Visible thought", providerMetadata: undefined },
|
||||||
{
|
{
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
id: "hosted-old-model",
|
id: "hosted-old-model",
|
||||||
|
|
|
||||||
|
|
@ -447,10 +447,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
|
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||||
|
if (!signature) {
|
||||||
|
content.push({ type: "text", text: part.text })
|
||||||
|
continue
|
||||||
|
}
|
||||||
content.push({
|
content.push({
|
||||||
type: "thinking",
|
type: "thinking",
|
||||||
thinking: part.text,
|
thinking: part.text,
|
||||||
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
|
signature,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,9 +348,14 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
|
const signature = reasoningSignature(part)
|
||||||
|
if (!signature) {
|
||||||
|
content.push({ text: part.text })
|
||||||
|
continue
|
||||||
|
}
|
||||||
content.push({
|
content.push({
|
||||||
reasoningContent: {
|
reasoningContent: {
|
||||||
reasoningText: { text: part.text, signature: reasoningSignature(part) },
|
reasoningText: { text: part.text, signature },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
const signature = thoughtSignature(part.providerMetadata)
|
||||||
|
parts.push(signature ? { text: part.text, thought: true, thoughtSignature: signature } : { text: part.text })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
|
|
|
||||||
|
|
@ -383,9 +383,12 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
flushText()
|
|
||||||
const reasoning = lowerReasoning(part)
|
const reasoning = lowerReasoning(part)
|
||||||
if (!reasoning) continue
|
if (!reasoning) {
|
||||||
|
content.push({ type: "text", text: part.text })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flushText()
|
||||||
if (store !== false) {
|
if (store !== false) {
|
||||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||||
reasoningReferences.add(reasoning.id)
|
reasoningReferences.add(reasoning.id)
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,18 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare(
|
||||||
|
LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
messages: [{ role: "assistant", content: [{ type: "text", text: "foreign thought" }] }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,20 @@ describe("Bedrock Converse route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })],
|
||||||
|
cache: "none",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: [{ text: "foreign thought" }] }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits provider-error for throttlingException", () =>
|
it.effect("emits provider-error for throttlingException", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,16 @@ describe("Gemini route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||||
|
LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([{ role: "model", parts: [{ text: "foreign thought" }] }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents({
|
const body = sseEvents({
|
||||||
|
|
|
||||||
|
|
@ -818,6 +818,34 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers foreign reasoning without continuation state to assistant text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{ type: "text", text: "before" },
|
||||||
|
{ type: "reasoning", text: "foreign thought" },
|
||||||
|
{ type: "text", text: "after" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "output_text", text: "before" },
|
||||||
|
{ type: "output_text", text: "foreign thought" },
|
||||||
|
{ type: "output_text", text: "after" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ const messages = (input: readonly ModelMessage[]) => {
|
||||||
Message.make({
|
Message.make({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: content(message.content),
|
content: content(message.content),
|
||||||
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
native: isRecord(message.providerOptions) ? message.providerOptions : undefined,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||||
|
|
||||||
if (msg.info.role === "assistant") {
|
if (msg.info.role === "assistant") {
|
||||||
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
|
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
|
||||||
|
const replaysForeignReasoning =
|
||||||
|
model.api.npm === "@ai-sdk/openai-compatible" &&
|
||||||
|
typeof model.capabilities.interleaved === "object" &&
|
||||||
|
model.capabilities.interleaved.field === "reasoning_content"
|
||||||
const media: Array<{ mime: string; url: string; filename?: string }> = []
|
const media: Array<{ mime: string; url: string; filename?: string }> = []
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -360,7 +364,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
if (differentModel) {
|
if (differentModel && !replaysForeignReasoning) {
|
||||||
if (part.text.trim().length > 0)
|
if (part.text.trim().length > 0)
|
||||||
assistantMessage.parts.push({
|
assistantMessage.parts.push({
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
@ -371,7 +375,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||||
assistantMessage.parts.push({
|
assistantMessage.parts.push({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: part.text,
|
text: part.text,
|
||||||
providerMetadata: part.metadata,
|
...(differentModel ? {} : { providerMetadata: part.metadata }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,39 @@ describe("session.llm-native.request", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.effect("preserves OpenAI-compatible reasoning fields through the native adapter", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const compatible = {
|
||||||
|
...baseModel,
|
||||||
|
providerID: ProviderV2.ID.make("moonshotai"),
|
||||||
|
api: {
|
||||||
|
id: "kimi-k2.5",
|
||||||
|
url: "https://api.moonshot.test/v1",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
...baseModel.capabilities,
|
||||||
|
interleaved: { field: "reasoning_content" as const },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const prepared = yield* prepareNativeRequest({
|
||||||
|
model: compatible,
|
||||||
|
apiKey: "test-key",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "answer" }],
|
||||||
|
providerOptions: { openaiCompatible: { reasoning_content: "thinking" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
messages: [{ role: "assistant", content: "answer", reasoning_content: "thinking" }],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("selects native request routes for provider packages", () => {
|
test("selects native request routes for provider packages", () => {
|
||||||
const openai = LLMNative.model({
|
const openai = LLMNative.model({
|
||||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||||
|
|
|
||||||
|
|
@ -684,6 +684,42 @@ describe("session.message-v2.toModelMessage", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves foreign reasoning for OpenAI-compatible reasoning fields", async () => {
|
||||||
|
const assistantID = "m-assistant"
|
||||||
|
const compatible = {
|
||||||
|
...model,
|
||||||
|
api: { ...model.api, npm: "@ai-sdk/openai-compatible" },
|
||||||
|
capabilities: { ...model.capabilities, reasoning: true, interleaved: { field: "reasoning_content" } },
|
||||||
|
} satisfies Provider.Model
|
||||||
|
const input: SessionV1.WithParts[] = [
|
||||||
|
{
|
||||||
|
info: assistantInfo(assistantID, "m-user", undefined, { providerID: "other", modelID: "other" }),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...basePart(assistantID, "a1"),
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thinking",
|
||||||
|
metadata: { anthropic: { signature: "foreign" } },
|
||||||
|
time: { start: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...basePart(assistantID, "a2"),
|
||||||
|
type: "text",
|
||||||
|
text: "answer",
|
||||||
|
},
|
||||||
|
] as SessionV1.Part[],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(ProviderTransform.message(await MessageV2.toModelMessages(input, compatible), compatible, {})).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "answer" }],
|
||||||
|
providerOptions: { openaiCompatible: { reasoning_content: "thinking" } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("replaces compacted tool output with placeholder", async () => {
|
test("replaces compacted tool output with placeholder", async () => {
|
||||||
const userID = "m-user"
|
const userID = "m-user"
|
||||||
const assistantID = "m-assistant"
|
const assistantID = "m-assistant"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ SessionExecution.resume(sessionID)
|
||||||
|
|
||||||
The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
|
The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
|
||||||
|
|
||||||
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
|
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native metadata replays only while the historical assistant model matches the selected continuation model. After a model switch, visible reasoning remains canonical without opaque metadata until the target protocol either replays it in a compatible reasoning field or lowers unsigned reasoning to ordinary assistant text.
|
||||||
|
|
||||||
## Context Epochs
|
## Context Epochs
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue