Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/adapters/kiro-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { kiroTruncationReason } from "./kiro-truncation";

export type ParsedKiroEvent =
| { type: "content"; data?: string; modelId?: string }
| { type: "reasoning"; data?: string }
| { type: "reasoning"; data?: string; redactedContent?: string }
| { type: "context_usage"; contextUsagePercentage: number }
| { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
| { type: "truncation"; data: string }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
Expand All @@ -17,6 +18,10 @@ const KNOWN_EVENT_TYPES = new Set([
"toolUseEvent",
"messageMetadataEvent",
"metadataEvent",
// Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
// HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
// contextUsagePercentage stays supported as a fallback rather than being dropped.
"contextUsageEvent",
"invalidStateEvent",
"error",
]);
Expand Down Expand Up @@ -114,11 +119,17 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
: {}),
};
case "reasoningContentEvent":
// `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family
// (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not
// the model id. Both may be absent on a bare event.
return {
type: "reasoning",
...(optionalString(eventType, parsed, "text") !== undefined
? { data: optionalString(eventType, parsed, "text") }
: {}),
...(optionalString(eventType, parsed, "redactedContent") !== undefined
? { redactedContent: optionalString(eventType, parsed, "redactedContent") }
: {}),
};
case "toolUseEvent":
return {
Expand Down Expand Up @@ -161,6 +172,13 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
...(stopReason !== undefined ? { stopReason } : {}),
};
}
case "contextUsageEvent": {
const contextUsagePercentage = parsed.contextUsagePercentage;
if (typeof contextUsagePercentage !== "number" || !Number.isFinite(contextUsagePercentage)) {
return malformed(eventType, "contextUsagePercentage must be a finite number");
}
return { type: "context_usage", contextUsagePercentage };
}
case "invalidStateEvent":
return { type: "invalid_state", message: optionalString(eventType, parsed, "message") };
case "error":
Expand Down
41 changes: 35 additions & 6 deletions src/adapters/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ interface KiroUserInputMessage {
}
interface KiroHistoryEntry {
userInputMessage?: KiroUserInputMessage;
assistantResponseMessage?: { content: string; toolUses?: KiroToolUse[] };
assistantResponseMessage?: {
content: string;
toolUses?: KiroToolUse[];
reasoningContent?: { redactedContent: string };
};
}

function kiroToolWireNames(tools: readonly unknown[]): string[] {
Expand Down Expand Up @@ -326,7 +330,7 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {

type KiroTurn =
| { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
| { kind: "assistant"; content: string; toolUses: KiroToolUse[] };
| { kind: "assistant"; content: string; toolUses: KiroToolUse[]; redactedReasoning?: string };

function appendTurnText(target: string, next: string): string {
if (!next) return target;
Expand Down Expand Up @@ -471,13 +475,15 @@ export function buildKiroPayload(
turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
}
};
const pushAssistant = (content: string, toolUses: KiroToolUse[]): void => {
const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
const last = turns.at(-1);
if (last?.kind === "assistant") {
last.content = appendTurnText(last.content, content);
last.toolUses.push(...toolUses);
// Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
if (redactedReasoning) last.redactedReasoning = redactedReasoning;
} else {
turns.push({ kind: "assistant", content, toolUses: [...toolUses] });
turns.push({ kind: "assistant", content, toolUses: [...toolUses], ...(redactedReasoning ? { redactedReasoning } : {}) });
}
};

Expand Down Expand Up @@ -507,7 +513,7 @@ export function buildKiroPayload(
const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
if (hasReasoning) continue;
}
pushAssistant(text, toolUses);
pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning);
} else if (msg.role === "toolResult") {
const tr = msg as OcxToolResultMessage;
if (tr.containsEncryptedContent) {
Expand Down Expand Up @@ -561,6 +567,7 @@ export function buildKiroPayload(
assistantResponseMessage: {
content: turn.content,
...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}),
...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}),
},
}
: {
Expand Down Expand Up @@ -884,6 +891,10 @@ async function* parseKiroAttemptEvents(
let outputChars = "";
let outputCharsBytes = 0;
let contextUsagePercentage: number | undefined;
// `contextUsageEvent` is the authoritative source; `metadataEvent.contextUsagePercentage` is a
// fallback for wires that carry it there. Once an authoritative value lands, a later fallback
// must not clobber it — otherwise event order alone decides which value survives.
let contextUsageIsAuthoritative = false;
let returnedConversationId = conversationId;
let assistantText = "";
let assistantTextBytes = 0;
Expand Down Expand Up @@ -1154,7 +1165,11 @@ async function* parseKiroAttemptEvents(
switch (ev.type) {
case "metadata":
if (ev.usage) authoritativeUsage = ev.usage;
if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) {
if (
!contextUsageIsAuthoritative
&& ev.contextUsagePercentage !== undefined
&& ev.contextUsagePercentage > 0
) {
contextUsagePercentage = ev.contextUsagePercentage;
}
if (ev.stopReason !== undefined) stopReason = ev.stopReason;
Expand Down Expand Up @@ -1183,6 +1198,20 @@ async function* parseKiroAttemptEvents(
if (ev.data) {
yield* emitRetained(stage({ type: "reasoning_raw_delta", text: ev.data }));
}
if (ev.redactedContent) {
yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: ev.redactedContent }));
Comment on lines +1201 to +1202

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delay fallback Kiro blobs until after final answer

When a tool-enabled Kiro request enters text_fallback and the bounded retry returns the private completion tool followed by redactedContent, staging this new event puts it in fallbackEvents; that terminal path yields fallbackEvents before emitting the extracted completionAnswer, so the krc-only reasoning item reaches the bridge before the final assistant message and the parser's backwards pairing drops or mis-pairs it. Hold the Kiro blob until after the extracted final answer (or attach it to that message) so Sol sessions retain reasoning after validated final-answer retries.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
break;
case "context_usage":
// Zero is a real reading (a fresh conversation), not "absent", so it must still claim
// authority — otherwise precedence would depend on the VALUE rather than the source and a
// trailing metadataEvent could override a genuine 0%. Negatives are malformed and
// rejected. contextUsageTotalFloor already discards a zero floor, so nothing downstream
// sees a bogus zero-token checkpoint.
if (ev.contextUsagePercentage >= 0) {
contextUsagePercentage = ev.contextUsagePercentage;
contextUsageIsAuthoritative = true;
}
break;
case "tool": {
for (const contentEvent of thinking.flush()) {
Expand Down
55 changes: 55 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,29 @@ export function bridgeToResponsesSSE(
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
// Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the
// assistant message is still open, so this CANNOT emit on arrival: the open message still
// owns `outputIndex` (it only advances on close), and an item emitted here would both reuse
// that index and land BEFORE the message — where the parser's backwards pairing drops it as
// orphaned. Stash it and flush after `done` has closed every open item instead.
let pendingKiroRedacted: string | undefined;
let pendingKiroRedactedBytes = 0;
const flushKiroRedactedReasoning = () => {
if (!pendingKiroRedacted) return;
const previousBytes = pendingKiroRedactedBytes;
const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted });
const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" });
pendingKiroRedacted = undefined;
pendingKiroRedactedBytes = 0;
reservation?.commitRetained();
budget?.releaseRetained(previousBytes, { kind: "reasoning" });
const itemId = `rs_${uuid()}`;
const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
emit("response.output_item.added", { output_index: outputIndex, item });
emit("response.output_item.done", { output_index: outputIndex, item });
retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning");
outputIndex++;
};
// Full assistant text of a compaction turn (across message boundaries) — becomes the
// synthetic compaction item's payload on done.
let compactionText = "";
Expand Down Expand Up @@ -869,6 +892,12 @@ export function bridgeToResponsesSSE(
pendingRedacted.push(event.data);
break;
}
case "kiro_redacted_reasoning": {
// Stash only — see flushKiroRedactedReasoning. One blob per turn, so last wins.
pendingKiroRedactedBytes = replaceRetainedString(pendingKiroRedactedBytes, event.data, "reasoning");
pendingKiroRedacted = event.data;
break;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "reasoning_raw_delta": {
if (options?.hideThinkingSummary) {
({ value: hiddenRawReasoningText, bytes: hiddenRawReasoningBytes } = appendString(
Expand Down Expand Up @@ -1039,6 +1068,9 @@ export function bridgeToResponsesSSE(
// Redacted-only turns (or hidden thinking without a trailing signature event) still
// need their envelope-only reasoning item so the blocks replay next turn.
flushHiddenReasoningEnvelope();
// After every close above, so the blob lands AFTER the assistant message it belongs
// to and the parser's backwards pairing finds it.
flushKiroRedactedReasoning();
if (options?.compaction) {
// Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
const item = {
Expand Down Expand Up @@ -1361,6 +1393,10 @@ function buildResponseJSONWithBudget(
let batchSignatureBytes = 0;
let batchRedacted: string[] = [];
let batchRedactedBytes = 0;
// Kiro reasoning blob, held until after the trailing flushes so it lands AFTER the assistant
// message (see the streaming path). Retained because it outlives releaseTranslatedEvent.
let batchKiroRedacted: string | undefined;
let batchKiroRedactedBytes = 0;
let currentToolCallId = "";
let currentToolCallName = "";
let currentToolCallArgs = "";
Expand Down Expand Up @@ -1528,6 +1564,16 @@ function buildResponseJSONWithBudget(
}
batchRedacted.push(e.data);
break;
case "kiro_redacted_reasoning":
// Stash only — pushed after the trailing flushes. One blob per turn, so last wins.
{
const dataBytes = bytesOf(e.data);
budget?.chargeRetained(dataBytes, { kind: "reasoning" });
if (batchKiroRedactedBytes > 0) budget?.releaseRetained(batchKiroRedactedBytes, { kind: "reasoning" });
batchKiroRedactedBytes = dataBytes;
}
batchKiroRedacted = e.data;
break;
case "reasoning_raw_delta":
if (currentText) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
Expand Down Expand Up @@ -1626,6 +1672,15 @@ function buildResponseJSONWithBudget(
flushRawReasoning();
// Open tool call on a failed/incomplete turn must not land as status:"completed".
if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
if (batchKiroRedacted) {
// pushOutput reserves the item itself and releases the retained raw blob it replaces.
pushOutput({
type: "reasoning", id: `rs_${uuid()}`, summary: [],
encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }),
}, batchKiroRedactedBytes, "reasoning");
batchKiroRedacted = undefined;
batchKiroRedactedBytes = 0;
}
// A truncated turn must never be installed as replacement history: emit the
// compaction item only when the turn actually completed (#422).
if (
Expand Down
12 changes: 12 additions & 0 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,18 @@ export function parseRequest(body: unknown): OcxParsedRequest {
: null;
const thinkingText = envelope?.txt || text;

// Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider
// state for the assistant turn that ALREADY closed, because Kiro emits its
// reasoningContentEvent at the END of a turn (after content AND tool calls, verified
// against kiro-cli 2.14.1/2.16.0). Folding it into the FOLLOWING turn like ordinary
// reasoning would attach turn N's blob to turn N+1, so attach it backwards instead. With
// no assistant turn to own it the blob is dropped rather than mis-paired.
if (envelope?.krc && thinkingText.length === 0) {
const previous = messages[messages.length - 1];
if (previous?.role === "assistant") previous.kiroRedactedReasoning = envelope.krc;
continue;
}

// Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
// assistant turn or invent replayable plaintext/signatures from the encrypted payload.
if (thinkingText.length > 0) {
Expand Down
10 changes: 9 additions & 1 deletion src/responses/reasoning-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface ReasoningEnvelope {
* so replay needs it even though the visible summary was suppressed.
*/
txt?: string;
/**
* Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to
* the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve
* model reasoning across turns, so it round-trips here the same way a signature does.
*/
krc?: string;
}

export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
Expand All @@ -45,7 +51,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
}
const txt = (parsed as { txt?: unknown }).txt;
if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
return envelope.sig || envelope.red || envelope.txt ? envelope : null;
const krc = (parsed as { krc?: unknown }).krc;
if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
} catch {
return null;
}
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ export interface OcxAssistantMessage {
phase?: OcxMessagePhase;
model?: string;
timestamp: number;
/**
* Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob
* Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so
* it rides the message rather than a content part: any other adapter simply ignores it.
*/
kiroRedactedReasoning?: string;
}

export interface OcxDeveloperMessage {
Expand Down Expand Up @@ -254,6 +260,9 @@ export type AdapterEvent =
// opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400.
| { type: "thinking_signature"; signature: string }
| { type: "redacted_thinking"; data: string }
// Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn.
// Never rendered — it only rides the reasoning item's envelope so the next request can replay it.
| { type: "kiro_redacted_reasoning"; data: string }
| { type: "reasoning_raw_delta"; text: string }
| { type: "tool_call_start"; id: string; name: string }
| { type: "tool_call_delta"; arguments: string }
Expand Down
40 changes: 40 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,46 @@ Grounded in the open-sourced official client (xai-org/grok-build); unit + eviden
`fetchWithHeaderTimeout` takes an executor so provider fetch wrappers stay inside the
timeout race.

## Kiro reasoning round-trip (`redactedContent`)

Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`,
`-luna`): `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, never `text`.
Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort`
with `additionalProperties: false` — there is no display/summary opt-in, so this is the only
reasoning these models can return. Kiro's own CLI replays the blob on the matching
`assistantResponseMessage.reasoningContent` to preserve model reasoning across turns; dropping it
makes every turn restart without the previous turn's reasoning. Verified on kiro-cli 2.14.1 and
2.16.0, all three models.

The Claude 4.6+/5 entries advertise a different, richer contract (`thinking.type` adaptive/disabled,
`thinking.display` summarized/omitted, `output_config.effort`, `max_tokens`) and are not covered by
that measurement; older Claude, deepseek, minimax, glm, and qwen entries advertise no additional
fields at all. The handling below keys off the wire field, not the model id, so any model that
sends `redactedContent` round-trips.

- The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on
an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the
Codex app while round-tripping, exactly like the hidden-thinking path.
- **Pairing is backwards.** Kiro emits `reasoningContentEvent` at the END of an assistant turn,
after content AND tool calls. A `krc`-only item therefore belongs to the turn that already
closed, so the parser attaches it to the PRECEDING assistant message rather than folding it into
the following turn like ordinary reasoning (`src/responses/parser.ts`). With no assistant turn to
own it, the blob is dropped rather than mis-paired.
- The blob lives on `OcxAssistantMessage.kiroRedactedReasoning`, not on a thinking content part, so
no other adapter replays provider-private state if the conversation switches providers.

Kiro reports context pressure in its own `contextUsageEvent`, which is the authoritative source. On
every capture taken (2.14.1 and 2.16.0) `metadataEvent` carried only `stopReason` — which is why
reading the percentage from `metadataEvent` alone never saw a value — but the parser still accepts a
finite `contextUsagePercentage` (and a `tokenUsage` block) there as a fallback, so a value parsed
from `metadataEvent` is legitimate rather than impossible. Precedence is by SOURCE, not arrival
order: once a `contextUsageEvent` value lands, a later `metadataEvent` percentage is ignored, so a
trailing fallback frame cannot clobber the authoritative one.

Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried
`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently
ignored because a credit is not a token count.

## Parallel tool calls (default-on for chat providers)

The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to
Expand Down
Loading
Loading