Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -244,6 +244,96 @@ const REPLAY_CHUNK_TYPES = new Set<StreamChunk['type']>([
|
||||
'finish',
|
||||
])
|
||||
|
||||
const FROM_REQUEST_OPEN = '{{fromRequest:'
|
||||
const FROM_REQUEST_CLOSE = '}}'
|
||||
|
||||
/** Collect every string leaf of one JSON-shaped value, in traversal order. */
|
||||
function collectStrings(value: unknown, out: string[]): void {
|
||||
if (typeof value === 'string') {
|
||||
out.push(value)
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectStrings(item, out)
|
||||
return
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) collectStrings(item, out)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */
|
||||
function resolveFromRequest(pattern: string, corpus: string): string {
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(pattern, 'g')
|
||||
} catch (error) {
|
||||
// RegExp construction only throws SyntaxError; String() carries its message.
|
||||
throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`)
|
||||
}
|
||||
let last: RegExpExecArray | undefined
|
||||
for (const match of corpus.matchAll(regex)) last = match
|
||||
if (last === undefined) {
|
||||
throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`)
|
||||
}
|
||||
return last[1] ?? last[0]
|
||||
}
|
||||
|
||||
/** Replace every `{{fromRequest:<pattern>}}` occurrence in one scripted string. */
|
||||
function substituteString(text: string, corpus: string): string {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
while (true) {
|
||||
const open = text.indexOf(FROM_REQUEST_OPEN, cursor)
|
||||
if (open === -1) return result + text.slice(cursor)
|
||||
let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length)
|
||||
if (close === -1) {
|
||||
throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`)
|
||||
}
|
||||
// The last two braces of a consecutive `}` run terminate the placeholder,
|
||||
// so a pattern may end with a brace quantifier like `[0-9a-f]{4}`.
|
||||
while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1
|
||||
const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close)
|
||||
result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus)
|
||||
cursor = close + FROM_REQUEST_CLOSE.length
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */
|
||||
function substituteValue(value: unknown, corpus: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)]))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every `{{fromRequest:<regex>}}` placeholder in one scripted entry
|
||||
* against the live request. The corpus is every string leaf of the request
|
||||
* messages joined by newlines; the pattern's LAST corpus match wins and its
|
||||
* first capture group (or, without one, the whole match) substitutes in place.
|
||||
* Scenario sidecars use this to script arguments no static file can know,
|
||||
* such as a randomly minted goal id the model must echo back. A pattern that
|
||||
* matches nothing, an invalid pattern, and an unterminated placeholder each
|
||||
* fail loud. The last two braces of a consecutive `}` run terminate the
|
||||
* placeholder, so a pattern may end with a brace quantifier but cannot
|
||||
* contain `}}` followed by further pattern content. Derived entries pass
|
||||
* through the same resolution as sidecar entries.
|
||||
* @param entry - the scripted entry about to replay.
|
||||
* @param messages - the live request messages searched by the placeholders.
|
||||
* @returns the entry itself when no placeholder appears, else a resolved deep copy.
|
||||
*/
|
||||
export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry {
|
||||
if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry
|
||||
const leaves: string[] = []
|
||||
collectStrings(messages, leaves)
|
||||
return substituteValue(entry, leaves.join('\n')) as ReplayEntry
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -586,7 +676,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand
|
||||
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
|
||||
)
|
||||
}
|
||||
yield* replayEntry(entry, options.signal, paceMs)
|
||||
yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs)
|
||||
})()
|
||||
}
|
||||
const providers = config.providers ?? []
|
||||
|
||||
Reference in New Issue
Block a user