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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
"acl-error-classification.test.ts": "lib",
"active-registry-admission.test.ts": "codex-integration",
"adapter-buffered-tool-conformance.test.ts": "adapters",
"adapter-dispatch-local-terminal.test.ts": "responses",
"adapter-error-inline.test.ts": "adapters",
"adapter-event-oauth-failover.test.ts": "oauth",
"adapter-inner-send-budget-wiring.test.ts": "adapters",
Expand Down
2 changes: 1 addition & 1 deletion src/providers/reasoning-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ function modelLadderValue(
* so both sides still bind learned refusals to the same wire credential, and a rotation behind
* a stable reference starts clean instead of inheriting the previous credential's refusals.
*/
function credentialIdentity(provider: OcxProviderConfig): string | undefined {
export function credentialIdentity(provider: OcxProviderConfig): string | undefined {
const resolved = provider._apiKeyAttempt?.reference !== undefined
? provider.apiKey
: resolveProviderApiKey(provider.apiKey);
Expand Down
28 changes: 22 additions & 6 deletions src/responses/turn-termination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface DeliveredFinalAnswerRecord {
createdAt: number;
}

const scopesByRequest = new WeakMap<OcxParsedRequest, string>();
const scopesByRequest = new WeakMap<OcxParsedRequest, string | (() => string | undefined)>();
const deliveredFinalAnswers = new Map<string, DeliveredFinalAnswerRecord>();

function pruneDeliveredFinalAnswers(at = Date.now()): void {
Expand Down Expand Up @@ -58,16 +58,32 @@ function deliveredFinalAnswerText(response: unknown): string | undefined {
}

/** Bind the normalized per-conversation digest without adding proxy-private fields to the wire body. */
export function bindTurnTerminationScope(parsed: OcxParsedRequest, scope: string | undefined): void {
// Only the normalized log-conversation digest may key this process-wide map. Refusing any raw
// fallback prevents a future caller from retaining a client header or account identifier here.
export function bindTurnTerminationScope(
parsed: OcxParsedRequest,
scope: string | (() => string | undefined) | undefined,
): void {
// Only the normalized log-conversation digest may key this process-wide map. A bound string must
// already BE that digest; a resolver is deferred to read time so a credential rotation after this
// bind lands the record under the identity that actually served the answer. Both paths enforce the
// digest shape -- refusing any raw value prevents a future caller from retaining a client header
// or account identifier here.
if (typeof scope === "function") {
scopesByRequest.set(parsed, scope);
return;
}
if (!scope || !/^[0-9a-f]{32}$/.test(scope)) return;
scopesByRequest.set(parsed, scope);
}

function recordedScope(parsed: OcxParsedRequest): string | undefined {
const bound = scopesByRequest.get(parsed);
const scope = typeof bound === "function" ? bound() : bound;
return typeof scope === "string" && /^[0-9a-f]{32}$/.test(scope) ? scope : undefined;
}

/** Remember only a final-answer message the proxy actually emitted for this exact conversation. */
export function rememberDeliveredFinalAnswer(parsed: OcxParsedRequest, response: unknown): void {
const scope = scopesByRequest.get(parsed);
const scope = recordedScope(parsed);
if (!scope) return;
const text = deliveredFinalAnswerText(response);
if (!text) return;
Expand All @@ -88,7 +104,7 @@ export function hasRecordedTrailingDeliveredFinalAnswer(
parsed: OcxParsedRequest,
messages: readonly OcxMessage[],
): boolean {
const scope = scopesByRequest.get(parsed);
const scope = recordedScope(parsed);
if (!scope) return false;
pruneDeliveredFinalAnswers();
const record = deliveredFinalAnswers.get(scope);
Expand Down
20 changes: 15 additions & 5 deletions src/server/request-log-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,22 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null {
* pair separates siblings while still keeping one conversation's overlapping turns together.
*/
export function sessionLaneIdFromRequest(headers: Headers): string | undefined {
return sessionSpecificLaneIdFromRequest(headers)
?? normalizeLogConversationId(headers.get("x-codex-parent-thread-id"));
}

/**
* The lane's child-specific half only: the thread or session a request actually owns, still
* qualified by its parent when both exist. A bare parent is a coalescing group, never an exact
* conversation, so callers that need one child's identity — the Kiro termination scope — must
* take this and refuse parent-only requests rather than collapse siblings into a shared key.
*/
export function sessionSpecificLaneIdFromRequest(headers: Headers): string | undefined {
const specific = normalizeLogConversationId(headers.get("thread-id"))
?? normalizeLogConversationId(sessionIdHeaderFromRequest(headers));
if (!specific) return undefined;
const parent = normalizeLogConversationId(headers.get("x-codex-parent-thread-id"));
const thread = normalizeLogConversationId(headers.get("thread-id"));
const session = normalizeLogConversationId(sessionIdHeaderFromRequest(headers));
const specific = thread ?? session;
if (parent && specific) return `${parent}\u0000${specific}`;
return specific ?? parent;
return parent ? `${parent}\u0000${specific}` : specific;
}

function firstSanitizedConversationId(
Expand Down
29 changes: 28 additions & 1 deletion src/server/responses/adapter-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export async function prepareAdapterExchange(
| "genericFailovers"
| "applyFailoverSnapshot"
| "noteRoutedAttemptSend"
| "selectionIsCurrent"
| "adapterBindings"
| "refreshDispatchAdapter"
>,
responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
sendBudgetState: Pick<
Expand All @@ -148,6 +151,9 @@ export async function prepareAdapterExchange(
anthropicSessionKey,
commitResolvedOAuthSelection,
applyFailoverSnapshot,
selectionIsCurrent,
adapterBindings,
refreshDispatchAdapter,
} = transportState;
const {
parsed,
Expand Down Expand Up @@ -194,7 +200,28 @@ export async function prepareAdapterExchange(
// sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
// terminal as a failed turn and re-invokes the identical request. Routing this through the
// ordinary event path would therefore reinstate the loop it exists to end.
const localTerminal = transportState.activeAdapter.localTerminal?.(parsed);
// A bound termination scope resolves its serving credential lazily, so it must read the same
// selection the upcoming send would use. Selection is mutable while a request waits: run the
// dispatch binding's revalidation before evaluating a local terminal, or a stale-credential hit
// suppresses work the send path would have moved onto the newly selected credential.
let selectionCurrent = true;
if (
transportState.activeAdapter.localTerminal
&& !selectionIsCurrent(adapterBindings.get(transportState.activeAdapter))
) {
try {
await refreshDispatchAdapter(parsed);
bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: route.provider,
adapterName: transportState.activeAdapter.name,
oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot });
} catch {
// A failed refresh leaves the route stale; a scope evaluated under it could still hit a
// record the replaced credential made and return success where the credential error
// belongs. The send path re-validates and surfaces that error on its own terms.
selectionCurrent = false;
}
}
const localTerminal = selectionCurrent ? transportState.activeAdapter.localTerminal?.(parsed) : undefined;
if (localTerminal) {
logCtx.localTerminalReason = localTerminal.reason;
// Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
Expand Down
4 changes: 1 addition & 3 deletions src/server/responses/request-prepare.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { buildToolBridgeMaps } from "./collaboration";
import { parseRequest } from "../../responses/parser";
import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
import { rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
import { observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
import type { RouteResult } from "../../router";
import {
Expand Down Expand Up @@ -388,7 +388,6 @@ export async function prepareResponsesRequest(
threadIdHeader: req.headers.get("thread-id"),
cursorConversationId: parsed._cursorConversationId,
});
bindTurnTerminationScope(parsed, resolvedConversationId);
const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
};
Expand Down Expand Up @@ -785,7 +784,6 @@ export async function prepareResponsesRequest(
(reparsed as unknown as Record<string, unknown>)[key] = parsed[key];
}
}
bindTurnTerminationScope(reparsed, resolvedConversationId);
parsed = reparsed;
// The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task
// text. Bar it from the continuation cache before any recording path can reach it —
Expand Down
42 changes: 41 additions & 1 deletion src/server/responses/request-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
noteGenericPoolSelection,
} from "../../oauth/generic-account-failover";
import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label";
import { credentialIdentity } from "../../providers/reasoning-metadata";
import { resolveProviderTransport } from "../../providers/xai-transport";
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
import {
Expand All @@ -48,7 +49,9 @@ import { recordAnthropicAccountQuotaFromHeaders, hasPassiveAccountQuota } from "
import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
import { formatErrorResponse } from "../../bridge";
import { bindRouteReasoningReplayScope } from "./core-replay";
import { sessionIdHeaderFromRequest, normalizeLogConversationId } from "../request-log-conversation";
import { sessionIdHeaderFromRequest, sessionLaneIdFromRequest, sessionSpecificLaneIdFromRequest, normalizeLogConversationId } from "../request-log-conversation";
import { bindTurnTerminationScope } from "../../responses/turn-termination";
import { codexLogAccountId } from "./core-codex-account";
import { redactSecretString } from "../../lib/redact";
import { selectProactiveApiKeyTransport } from "../../providers/key-failover";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
Expand Down Expand Up @@ -659,6 +662,42 @@ export async function prepareResponsesTransport(
delete logCtx.accountLogLabel;
}
adapter = resolveSelectionAdapter(adapterProvider, config.cacheRetention);
if (adapter.name === "kiro") {
// A log conversation deliberately coalesces a parent's parallel subagents, but a delivered
// final answer may suppress work only for the exact child and serving identity that emitted it.
// Conversation and admission are bound to the admitted request and captured eagerly; the serving
// credential is resolved lazily at check/record time instead. Kiro key-pool and OAuth failover
// can swap the physical transport after this bind -- a record pinned to the credential that
// failed would replay or suppress under the wrong identity, and `route.provider` is the field
// every rotation site rewrites, so reading it late tracks the credential that actually served.
// Hash the composite before binding so no caller, account, key, or route identifier is retained.
// A parent-only lane is a coalescing group, not a child: two siblings that send only
// `x-codex-parent-thread-id` would share one scope and suppress each other, the exact leak
// this guard exists to close. Only a thread/session id or the Cursor conversation counts.
const exactConversation = sessionSpecificLaneIdFromRequest(req.headers)
?? normalizeLogConversationId(parsed._cursorConversationId);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const admissionIdentity = options.admission?.kind === "configured"
? `configured:${options.admission.keyId}`
: options.admission?.kind;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
bindTurnTerminationScope(parsed, exactConversation
? () => {
const provider = route.provider;
// credentialIdentity hashes the resolved wire credential, not the configured
// reference: an env:keychain-backed key can rotate behind a stable expression,
// and the scope must follow what was actually sent, never the retained secret.
const servingAccount = replayOAuthCredentialSnapshot?.accountId
?? (usesApiKeyAccount(provider) ? credentialIdentity(provider) : undefined)
?? codexLogAccountId(admissionState.authCtx);
return normalizeLogConversationId(JSON.stringify([
exactConversation,
admissionIdentity,
route.providerName,
route.modelId,
servingAccount,
]));
}
: undefined);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
bindRouteReasoningReplayScope({
parsed,
providerName: route.providerName,
Expand Down Expand Up @@ -813,6 +852,7 @@ export async function prepareResponsesTransport(
applyFailoverSnapshot,
selectionIsCurrent,
resolveSelectionAdapter,
refreshDispatchAdapter,
refreshRunTurnAdapter,
oauthDispatch,
noteRoutedAttemptSend,
Expand Down
2 changes: 1 addition & 1 deletion structure/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Per-vendor contracts and the adapter authority that constructs them.
| [`providers/openai-tiers.md`](providers/openai-tiers.md) | Pool/Direct account modes, API-key separation, wire identity, and quota evidence. |
| [`providers/cursor.md`](providers/cursor.md) | Cursor native exec, parameterized models, checkpoints, and active-context usage. |
| [`providers/google.md`](providers/google.md) | Gemini thought-text, response parts, thought-signature replay, and adjacency repair. |
| [`providers/kiro.md`](providers/kiro.md) | Kiro parallel-tool hints, Responses text controls, and reasoning round-trip. |
| [`providers/kiro.md`](providers/kiro.md) | Kiro parallel-tool hints, Responses text controls, reasoning round-trip, and delivered final-answer termination scope. |
| [`providers/xai-grok.md`](providers/xai-grok.md) | Grok Build contract parity and hardening. |
| [`providers/chat-compat.md`](providers/chat-compat.md) | Cross-vendor Chat Completions behavior: reasoning, tool results, structured output, parallel tools. |
| [`adapters/registry.md`](adapters/registry.md) | The single adapter construction authority and contract inheritance. |
Expand Down
2 changes: 1 addition & 1 deletion structure/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@
"path": "providers/kiro.md",
"tier": 4,
"title": "Kiro Provider",
"scope": "Kiro parallel-tool hints, Responses text controls, and reasoning round-trip.",
"scope": "Kiro parallel-tool hints, Responses text controls, reasoning round-trip, and delivered final-answer termination scope.",
"documents": [
"src/responses/"
]
Expand Down
47 changes: 47 additions & 0 deletions structure/providers/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,53 @@ positive value overwrites an earlier 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.
## Delivered final-answer termination scope

A delivered `final_answer` may close a Kiro turn only for the exact request that emitted it.
`src/responses/turn-termination.ts` keeps a process-wide map of delivered-answer fingerprints
keyed by a bound scope rather than by the parsed request's fields. The scope is bound in
`src/server/responses/request-transport.ts` after the final adapter is resolved, and only when
that adapter is `kiro`: the digest covers the conversation lane (`sessionSpecificLaneIdFromRequest`,
or the normalized Cursor conversation id when no lane headers exist), the admission identity, the
routed provider and model, and the serving credential. The composite is hashed before binding, so
no caller, account, key or route identifier is retained, and a request with no conversation
identity binds no scope at all. The lane must name a child: a request carrying only
`x-codex-parent-thread-id` gets the coalescing group as its lane, which every sibling under that
parent shares, so a parent-only request binds no scope rather than collapsing the group into one
conversation.

Conversation, admission, and route are captured eagerly — they belong to the admitted request —
but the serving credential resolves lazily at check/record time. Kiro key-pool and OAuth failover
can swap the physical transport after the bind, and every rotation site rewrites
`route.provider`, so a deferred resolver lands the record under the credential that actually
served instead of the one that failed. For key-authenticated routes the credential element is
`credentialIdentity` — the digest of the resolved wire credential, not the configured
`env:`/`keychain:` reference, so a rotation behind a stable expression terminates the old
identity's scope; an OAuth snapshot account id wins when one is bound, and the Codex auth
context is the last resort — different keys therefore never share a scope.

Because the credential resolves lazily, the check must read the same selection the upcoming send
would use, and selection is mutable while a request waits. `prepareAdapterExchange` therefore
runs the dispatch binding's staleness check (`selectionIsCurrent`, `refreshDispatchAdapter`) before
evaluating `localTerminal`, so a record a since-replaced credential made cannot suppress work the
send path would have moved onto the live one. A failed refresh leaves the route stale, so the
terminal is skipped entirely and the ordinary send path surfaces the credential error instead
of a stale-credential hit masquerading as success.

A bare log-conversation digest is too wide here: it deliberately coalesces a parent's parallel
subagents, and a scope that coarse would let one child's delivered answer suppress a sibling's
unfinished work. Nothing in `request-prepare.ts` binds the scope — including encrypted-task
recovery, whose reparsed body reaches the same transport binding — so the transport write is the
only write of the scope.

`rememberDeliveredFinalAnswer` records the trailing `final_answer` text fingerprint at delivery
(`adapter-delivery.ts`, `run-turn-execution.ts`, `sidecar-execution.ts`), holding it for one hour
across at most 1,024 scopes. `hasTrailingDeliveredFinalAnswer` in
`src/adapters/kiro/conversation.ts` matches a later turn only while its trailing assistant text is
still the recorded answer, so `src/adapters/kiro/payload.ts` withholds the completion tool and
emits the neutral acknowledgement instead of a continuation prompt
(`tests/server/server-kiro-completion-e2e.test.ts`).

## Remote image references

Kiro's wire inlines base64 bytes only, so a remote `https` image reference cannot be
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene
| Owner | Responsibility |
| --- | --- |
| `request-prepare.ts` | Body parsing, combo handoff, final route, encrypted-task recovery and initial admission. |
| `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. |
| `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement, the Kiro turn-termination scope binding and same-target request identity. |
| `request-sidecar-auth.ts` | Sidecar credential resolution and vision preprocessing. |
| `response-effects.ts` | Completion notification, replay publication and live request-tool aliases. |
| `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. |
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"acl-error-classification.test.ts": "lib",
"active-registry-admission.test.ts": "codex-integration",
"adapter-buffered-tool-conformance.test.ts": "adapters",
"adapter-dispatch-local-terminal.test.ts": "responses",
"adapter-error-inline.test.ts": "adapters",
"adapter-event-oauth-failover.test.ts": "oauth",
"adapter-inner-send-budget-wiring.test.ts": "adapters",
Expand Down
Loading
Loading