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
40 changes: 26 additions & 14 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,24 +101,36 @@ streams the response back **untranslated**.

### Completion semantics

Kiro text events do not carry a dependable end-turn phase. When an ordinary client tool is present,
opencodex therefore adds a private `codex_kiro_final_answer` tool to the upstream request. Progress
text streams as commentary and cannot terminate the turn. The adapter consumes the private call,
emits its answer as final text, and never exposes the private tool to Codex or Claude Code.
When the web-search sidecar is active, this commentary still streams immediately; only the events
needed to decide whether the model requested a synthetic search remain buffered.

If Kiro emits progress without calling the completion tool, the adapter makes one continuation. That
single retry may finish with a validated private completion or plain final text. It cannot recurse:
an empty or reasoning-only retry is returned as retryable incomplete, while a real client tool call
keeps the turn open. If the retry only repeats the preceding commentary after whitespace
normalization, the duplicate output is suppressed while the turn still completes. Tool-free
Kiro assistant text carries no dependable end-turn phase of its own. Its terminal `metadataEvent`
can, however, carry a native `stopReason`. An `END_TURN` response holding plain assistant text with
no client tool call ends the turn directly, with that text emitted as the final answer and no extra
model round trip.

When the stop reason is absent or is anything other than `END_TURN`, the compatibility path applies.
If an ordinary client tool is present, opencodex adds a private `codex_kiro_final_answer` tool to
the upstream request; progress text streams as commentary and cannot terminate the turn. The adapter
consumes the private call, emits its answer as final text, and never exposes the private tool to
Codex or Claude Code. Because the stop reason only arrives at the end of the stream, assistant text
in a tool-enabled turn is held until either a real tool call starts (released as commentary) or the
stream ends (released as the final answer on `END_TURN`, otherwise as commentary). When the
web-search sidecar is active, released commentary still streams ahead of the terminal event; only
the events needed to decide whether the model requested a synthetic search remain buffered.

If Kiro emits progress without an `END_TURN` stop reason and without calling the completion tool,
the adapter makes one continuation. That single retry may finish with a validated private completion
or plain final text. It cannot recurse: an empty or reasoning-only retry is returned as retryable
incomplete, while a real client tool call keeps the turn open. If the retry only restates the
preceding commentary, the duplicate output is suppressed while the turn still completes.
Restatement is judged by shared in-order word sequence, ignoring whitespace, punctuation, and case,
so a reworded repeat is caught while a retry that appends real new content is kept. Tool-free
requests retain normal text completion behavior.

### Reasoning effort

`gpt-5.6-sol` has verified native effort support. Its selected `low`, `medium`, `high`, `xhigh`, or
`max` value is sent as `additionalModelRequestFields.reasoning.effort`. Other Kiro models currently
`gpt-5.6-sol` and `claude-opus-5` have verified native effort support, and each model family names
the request field differently. A selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent
as `additionalModelRequestFields.reasoning.effort` for `gpt-5.6-sol` and as
`additionalModelRequestFields.output_config.effort` for `claude-opus-5`. Other Kiro models currently
use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in
the user content because their native effort field has not been verified. Do not interpret an
advertised effort control on those models as proof of upstream-native reasoning support.
Comment on lines +104 to 136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate locale copies of the adapters reference page and check whether they mention Kiro's
# completion semantics / reasoning effort in a way that could now contradict the English rewrite.
fd -i 'adapters.md' docs-site/src/content/docs | grep -E '/(ja|ko|ru|zh-cn)/'

Repository: lidge-jun/opencodex

Length of output: 368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  docs-site/src/content/docs/ja/reference/adapters.md \
  docs-site/src/content/docs/ko/reference/adapters.md \
  docs-site/src/content/docs/ru/reference/adapters.md \
  docs-site/src/content/docs/zh-cn/reference/adapters.md
do
  echo "===== $f ====="
  wc -l "$f"
  # Show the relevant section around "Reasoning effort" or nearby Kiro-specific content.
  rg -n -C 4 'Reasoning effort|END_TURN|completion semantics|additionalModelRequestFields|effort|Kiro' "$f" || true
  echo
done

Repository: lidge-jun/opencodex

Length of output: 7201


Sync the locale copies with the Kiro rewrite. docs-site/src/content/docs/ja/reference/adapters.md:31-38, docs-site/src/content/docs/ko/reference/adapters.md:36-43, docs-site/src/content/docs/ru/reference/adapters.md:39-43, and docs-site/src/content/docs/zh-cn/reference/adapters.md:34-38 still carry the older generic reasoning_effort wording, and their Kiro sections do not include the new END_TURN / claude-opus-5 native-effort behavior. Update those translations or mark them stale so they do not drift from docs-site/src/content/docs/reference/adapters.md.

🧰 Tools
🪛 LanguageTool

[style] ~125-~125: Using “real” as an adverb is considered informal. Consider using “really” or “very”.
Context: ...at is caught while a retry that appends real new content is kept. Tool-free requests...

(REAL_REALLY)


[grammar] ~133-~133: Ensure spelling is correct
Context: ...RequestFields.output_config.effortforclaude-opus-5`. Other Kiro models currently use emulate...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/adapters.md` around lines 104 - 136, The
locale adapter documentation copies are outdated relative to the canonical Kiro
section and reasoning-effort guidance. Update the Kiro and reasoning-effort
sections in the Japanese, Korean, Russian, and Simplified Chinese adapter
documents to reflect the canonical `END_TURN`, continuation, and model-specific
native effort behavior, or explicitly mark each translation stale using the
documentation’s established convention.

Source: Path instructions

Expand Down
4 changes: 3 additions & 1 deletion src/adapters/kiro-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type ParsedKiroEvent =
| { type: "reasoning"; data?: string }
| { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
| { type: "truncation"; data: string }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
| { type: "message_metadata"; conversationId?: string }
| { type: "invalid_state"; message?: string }
| { type: "error"; reason?: string; message?: string };
Expand Down Expand Up @@ -141,12 +141,14 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
) {
return malformed(eventType, "contextUsagePercentage must be a finite number");
}
const stopReason = optionalString(eventType, parsed, "stopReason");
return {
type: "metadata",
...(parseTokenUsage(eventType, parsed.tokenUsage) !== undefined
? { usage: parseTokenUsage(eventType, parsed.tokenUsage) }
: {}),
...(typeof contextUsagePercentage === "number" ? { contextUsagePercentage } : {}),
...(stopReason !== undefined ? { stopReason } : {}),
};
}
case "invalidStateEvent":
Expand Down
141 changes: 141 additions & 0 deletions src/adapters/kiro-restatement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Near-duplicate detection for Kiro's bounded completion retry.
//
// Kiro sometimes answers as ordinary assistant text instead of calling the reserved completion
// tool. opencodex then issues one bounded continuation retry, and a noncompliant model often
// answers that retry by saying what it already said. Rendering both copies shows the user the same
// answer twice.
//
// Observed restatements rewrite freely: they swap phrases, reorder clauses, and repunctuate while
// preserving the content. Exact comparison therefore misses them. Two measured signals separate a
// restatement from a genuine answer:
//
// 1. how much of the longer text the two share as an in-order word sequence, which is high for a
// rewording and low for unrelated text;
// 2. the largest block of consecutive new words the retry introduces, which stays at phrase length
// for a rewording and reaches sentence length when the retry actually adds information.
//
// Requiring both keeps a retry that repeats the earlier commentary and then appends real new
// detail, which a similarity threshold alone would discard.
//
// The comparison is deliberately conservative. Suppressing a genuine answer loses information,
// while failing to suppress a duplicate is cosmetic.

/**
* Minimum word count, required on both sides, before inexact matching applies. Individual words
* carry the meaning of short texts, where `found` versus `fixed` inverts the message, so those must
* match word for word.
*/
const MIN_INEXACT_MATCH_WORDS = 40;

/**
* Percentage of the longer text that both texts must share as an in-order word sequence for the
* retry to count as a restatement. Across 58 adjacent commentary/final-answer pairs on record,
* observed restatements measured 74%, 76%, 81%, 84%, and 92%, while the next pair below those
* measured 39%. The outcome over that corpus is identical for any value from 50 through 70, because
* the inserted-run and growth bounds do the remaining separation, so this sits mid-plateau rather
* than on a knife edge.
*/
const RESTATEMENT_MATCH_PERCENT = 65;

/**
* Longest run of consecutive new words a restatement may introduce. Measured rewordings inserted at
* most five consecutive words, whereas a retry that adds real information contributes at least a
* clause.
*/
const MAX_INSERTED_WORD_RUN = 11;

/**
* Upper bound on the words compared from each side. Reconstructing the shared sequence needs a table
* proportional to the product of the two lengths, so this caps the work and the allocation. A
* restatement is already evident from its opening few hundred words.
*/
const MAX_COMPARE_WORDS = 400;

/**
* Percentage by which the retry may exceed the preceding commentary before it is treated as new
* content rather than a rewording. A retry that is markedly longer is adding information even when
* it opens with a repeat.
*/
const MAX_GROWTH_PERCENT = 120;

/** Reports whether `candidate` merely restates `previous` rather than adding material content. */
export function isKiroRestatement(previous: string, candidate: string): boolean {
const previousWords = comparableWords(previous);
const candidateWords = comparableWords(candidate);
if (previousWords.length === candidateWords.length && previousWords.every((word, i) => word === candidateWords[i])) {
return true;
}
if (previousWords.length < MIN_INEXACT_MATCH_WORDS || candidateWords.length < MIN_INEXACT_MATCH_WORDS) {
return false;
}
if (candidateWords.length * 100 > previousWords.length * MAX_GROWTH_PERCENT) return false;
const left = previousWords.slice(0, MAX_COMPARE_WORDS);
const right = candidateWords.slice(0, MAX_COMPARE_WORDS);
Comment on lines +72 to +73

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 Compare suffixes before suppressing long retries

When the preceding commentary exceeds 400 words and the retry keeps that prefix but appends a material section of at most 20%, both arrays are truncated to the identical first 400 words, so this returns true and the adapter suppresses all of the new final-answer content. Include the unexamined suffix in the decision—such as by sampling the tail or treating a differing suffix as new content—and add a regression case for a long repeated prefix followed by a short new section.

AGENTS.md reference: AGENTS.md:L75-L77

Useful? React with 👍 / 👎.

const shared = sharedWordSequence(left, right);
const longer = Math.max(left.length, right.length);
return shared.length * 100 >= longer * RESTATEMENT_MATCH_PERCENT
&& longestInsertedRun(right, shared) <= MAX_INSERTED_WORD_RUN;
}

/**
* Splits `text` into lowercase words with surrounding punctuation removed so that rewrapped,
* repunctuated, and recapitalized restatements still align.
*/
function comparableWords(text: string): string[] {
return text
.split(/\s+/)
.map(word => word.replace(/^[^\p{L}\p{N}]+/u, "").replace(/[^\p{L}\p{N}]+$/u, "").toLowerCase())
.filter(word => word.length > 0);
}

/**
* Returns the longest common subsequence of the two word lists, which is the text they share in
* order while tolerating insertions, substitutions, and deletions.
*/
function sharedWordSequence(previous: string[], candidate: string[]): string[] {
const width = candidate.length + 1;
const lengths = new Uint16Array((previous.length + 1) * width);
for (let i = 0; i < previous.length; i++) {
for (let j = 0; j < candidate.length; j++) {
lengths[(i + 1) * width + j + 1] = previous[i] === candidate[j]
? lengths[i * width + j] + 1
: Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]);
}
}
const shared: string[] = [];
let i = previous.length;
let j = candidate.length;
while (i > 0 && j > 0) {
if (previous[i - 1] === candidate[j - 1]) {
shared.push(previous[i - 1]);
i--;
j--;
} else if (lengths[(i - 1) * width + j] >= lengths[i * width + j - 1]) {
i--;
} else {
j--;
}
}
shared.reverse();
return shared;
}

/**
* Returns the longest run of consecutive `candidate` words that are absent from `shared`, which
* measures the largest single block of new text the candidate introduces.
*/
function longestInsertedRun(candidate: string[], shared: string[]): number {
let sharedIndex = 0;
let longest = 0;
let current = 0;
for (const word of candidate) {
if (sharedIndex < shared.length && shared[sharedIndex] === word) {
sharedIndex++;
current = 0;
} else {
current++;
longest = Math.max(longest, current);
}
}
return longest;
}
Loading
Loading