From 6fb661aebb7dc2ebadddc161f1faf43cb0e740c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:06 +0800 Subject: [PATCH 01/12] feat(context): add session-scoped dynamic context --- src/context/ContextRuntime.ts | 2 + src/context/DefaultContextRuntime.ts | 30 +++++ src/context/dynamic/DynamicContextStore.ts | 125 ++++++++++++++++++++ src/context/index.ts | 6 + tests/context/dynamic-context-store.spec.ts | 81 +++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 src/context/dynamic/DynamicContextStore.ts create mode 100644 tests/context/dynamic-context-store.spec.ts diff --git a/src/context/ContextRuntime.ts b/src/context/ContextRuntime.ts index 07540e0f..82e0c0a8 100644 --- a/src/context/ContextRuntime.ts +++ b/src/context/ContextRuntime.ts @@ -30,6 +30,8 @@ export type AgentContextCaptureTurnInput = ContextCaptureTurnInput; export type AgentContextRuntime = { prepareForModel(input: AgentContextPrepareInput): Promise; + /** Commits one-shot context only after the final request has been built and is about to execute. */ + commitPreparedContext?(input: { sessionId: string; turnId: string }): void; /** * Optional. Real implementations (e.g. `DefaultContextRuntime`) provide * this; minimal runtimes (`NullContextRuntime`) leave it undefined and the diff --git a/src/context/DefaultContextRuntime.ts b/src/context/DefaultContextRuntime.ts index 0d257dc7..83a62d57 100644 --- a/src/context/DefaultContextRuntime.ts +++ b/src/context/DefaultContextRuntime.ts @@ -17,6 +17,7 @@ import type { InstructionDiscovery, InstructionScope } from "./instructions/Inst import { MemoryAttachmentBuilder } from "./memory/MemoryAttachmentBuilder.js"; import type { MemoryResolver } from "./memory/MemoryResolver.js"; import { PromptAssembler } from "./prompt/PromptAssembler.js"; +import type { DynamicContextStore } from "./dynamic/DynamicContextStore.js"; import { MessageProjector } from "./projection/MessageProjector.js"; import type { ContextCaptureTurnInput, @@ -85,6 +86,8 @@ export type DefaultContextRuntimeOptions = { /** Timeout budget for MemoryResolver.retrieve during prepareForModel. */ memoryRetrievalTimeoutMs?: number; now?: () => Date; + /** Session-scoped hook context consumed immediately before a model request. */ + dynamicContext?: DynamicContextStore; }; const DEFAULT_MAX_CONTEXT_TOKENS = 8192; @@ -113,6 +116,7 @@ export class DefaultContextRuntime implements ContextRuntime { private readonly truncateSecondKeepRatio: number; private readonly memoryRetrievalTimeoutMs: number; private readonly now: () => Date; + private readonly dynamicContext?: DynamicContextStore; constructor(options: DefaultContextRuntimeOptions = {}) { this.extension = options.extension ?? new NullExtensionResolver(); @@ -137,6 +141,7 @@ export class DefaultContextRuntime implements ContextRuntime { this.truncateSecondKeepRatio = options.truncateSecondKeepRatio ?? DEFAULT_TRUNCATE_SECOND_RATIO; this.memoryRetrievalTimeoutMs = options.memoryRetrievalTimeoutMs ?? DEFAULT_MEMORY_RETRIEVAL_TIMEOUT_MS; this.now = options.now ?? (() => new Date()); + this.dynamicContext = options.dynamicContext; } async prepareForModel(input: ContextPrepareInput): Promise { @@ -146,6 +151,27 @@ export class DefaultContextRuntime implements ContextRuntime { messages: input.messages, maxMessages: input.maxMessages, }); + const dynamicContext = this.dynamicContext?.getPending(input.sessionId); + if (dynamicContext && dynamicContext.entries.length > 0) { + projection.messages.push({ + role: "user", + content: [{ + type: "text", + text: `\n${dynamicContext.merged}\n`, + }], + metadata: { + synthetic: true, + transient: true, + transientId: `dynamic-context:${input.sessionId}:${input.turnId}`, + purpose: "dynamic_context", + }, + }); + diagnostics.push({ + code: "dynamic_context_injected", + severity: "info", + message: `Injected ${dynamicContext.entries.length} dynamic context entr${dynamicContext.entries.length === 1 ? "y" : "ies"}.`, + }); + } for (const warning of projection.warnings) { diagnostics.push({ @@ -252,6 +278,10 @@ export class DefaultContextRuntime implements ContextRuntime { }; } + commitPreparedContext(input: { sessionId: string }): void { + this.dynamicContext?.consume(input.sessionId); + } + async applyToolResults(input: ContextToolResultInput): Promise { const diagnostics: ContextDiagnostic[] = []; let appended: CanonicalMessage = input.toolResultMessage; diff --git a/src/context/dynamic/DynamicContextStore.ts b/src/context/dynamic/DynamicContextStore.ts new file mode 100644 index 00000000..40e2528c --- /dev/null +++ b/src/context/dynamic/DynamicContextStore.ts @@ -0,0 +1,125 @@ +export type DynamicContextPriority = "critical" | "high" | "normal" | "low"; + +export type DynamicContextEntry = { + readonly sessionId: string; + readonly id: string; + readonly source: string; + readonly content: string; + readonly priority?: DynamicContextPriority; + readonly turnId?: string; + readonly expiresAt?: number; +}; + +export type PendingDynamicContext = { + readonly entries: readonly DynamicContextEntry[]; + readonly merged: string; +}; + +const PRIORITY_ORDER: Record = { + critical: 0, + high: 1, + normal: 2, + low: 3, +}; + +const CONTEXT_SEPARATOR = "\n\n---\n\n"; +const MAX_ENTRIES_PER_SESSION = 64; +const MAX_ENTRY_CHARS = 16_384; +const MAX_MERGED_CHARS = 65_536; + +/** Collects hook-produced context until the next model request consumes it. */ +export class DynamicContextStore { + private readonly sessions = new Map>(); + private readonly registrationOrders = new Map>(); + private nextRegistrationOrder = 0; + + register(entry: DynamicContextEntry): void { + const content = entry.content.trim().slice(0, MAX_ENTRY_CHARS); + if (content.length === 0) return; + + const session = this.sessions.get(entry.sessionId) ?? new Map(); + const key = entryKey(entry); + const orders = this.registrationOrders.get(entry.sessionId) ?? new Map(); + if (!orders.has(key)) { + orders.set(key, ++this.nextRegistrationOrder); + } + this.registrationOrders.set(entry.sessionId, orders); + session.set(key, { ...entry, content, priority: entry.priority ?? "normal" }); + this.trimSession(entry.sessionId, session); + this.sessions.set(entry.sessionId, session); + } + + getPending(sessionId: string, now = Date.now()): PendingDynamicContext { + const session = this.sessions.get(sessionId); + if (!session) return { entries: [], merged: "" }; + + this.pruneExpired(sessionId, session, now); + const sorted = [...session.values()].sort((a, b) => { + const priority = PRIORITY_ORDER[a.priority ?? "normal"] - PRIORITY_ORDER[b.priority ?? "normal"]; + if (priority !== 0) return priority; + return this.order(sessionId, a) - this.order(sessionId, b); + }); + const entries: DynamicContextEntry[] = []; + let remaining = MAX_MERGED_CHARS; + for (const entry of sorted) { + const separatorChars = entries.length === 0 ? 0 : CONTEXT_SEPARATOR.length; + if (remaining <= separatorChars) break; + remaining -= separatorChars; + const content = entry.content.slice(0, remaining); + if (!content) break; + entries.push(content === entry.content ? entry : { ...entry, content }); + remaining -= content.length; + } + return { entries, merged: entries.map((entry) => entry.content).join(CONTEXT_SEPARATOR) }; + } + + consume(sessionId: string, now = Date.now()): PendingDynamicContext { + const pending = this.getPending(sessionId, now); + this.clear(sessionId); + return pending; + } + + hasPending(sessionId: string, now = Date.now()): boolean { + return this.getPending(sessionId, now).entries.length > 0; + } + + clear(sessionId: string): void { + this.sessions.delete(sessionId); + this.registrationOrders.delete(sessionId); + } + + private pruneExpired( + sessionId: string, + session: Map, + now: number, + ): void { + for (const [key, entry] of session) { + if (entry.expiresAt !== undefined && entry.expiresAt <= now) { + session.delete(key); + this.registrationOrders.get(sessionId)?.delete(key); + } + } + if (session.size === 0) this.clear(sessionId); + } + + private order(sessionId: string, entry: DynamicContextEntry): number { + return this.registrationOrders.get(sessionId)?.get(entryKey(entry)) ?? 0; + } + + private trimSession(sessionId: string, session: Map): void { + if (session.size <= MAX_ENTRIES_PER_SESSION) return; + const worstFirst = [...session.entries()].sort(([, a], [, b]) => { + const priority = PRIORITY_ORDER[b.priority ?? "normal"] - PRIORITY_ORDER[a.priority ?? "normal"]; + if (priority !== 0) return priority; + return this.order(sessionId, b) - this.order(sessionId, a); + }); + for (const [key] of worstFirst.slice(0, session.size - MAX_ENTRIES_PER_SESSION)) { + session.delete(key); + this.registrationOrders.get(sessionId)?.delete(key); + } + } +} + +function entryKey(entry: Pick): string { + return JSON.stringify([entry.source, entry.id]); +} diff --git a/src/context/index.ts b/src/context/index.ts index bf1c6a29..2f31a387 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -25,6 +25,12 @@ export { type PromptAssemblerResult, type PromptAssemblerSections, } from "./prompt/PromptAssembler.js"; +export { + DynamicContextStore, + type DynamicContextEntry, + type DynamicContextPriority, + type PendingDynamicContext, +} from "./dynamic/DynamicContextStore.js"; export { MessageProjector, type MessageProjectorInput, diff --git a/tests/context/dynamic-context-store.spec.ts b/tests/context/dynamic-context-store.spec.ts new file mode 100644 index 00000000..95e73f6c --- /dev/null +++ b/tests/context/dynamic-context-store.spec.ts @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DynamicContextStore } from "../../src/context/dynamic/DynamicContextStore.js"; + +test("merges pending context by priority and consumes it once", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "session-1", source: "monitor", id: "status", content: "monitor status" }); + store.register({ + sessionId: "session-1", + source: "rules", + id: "src/app", + content: "rules for src/app", + priority: "critical", + }); + + const pending = store.getPending("session-1"); + assert.deepEqual(pending.entries.map((entry) => entry.source), ["rules", "monitor"]); + assert.equal(pending.merged, "rules for src/app\n\n---\n\nmonitor status"); + assert.equal(store.consume("session-1").merged, pending.merged); + assert.equal(store.hasPending("session-1"), false); +}); + +test("re-registering the same source and id replaces stale content without changing order", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "session-1", source: "rules", id: "src/app", content: "old" }); + store.register({ sessionId: "session-1", source: "memory", id: "current", content: "memory" }); + store.register({ sessionId: "session-1", source: "rules", id: "src/app", content: "new" }); + + const pending = store.getPending("session-1"); + assert.deepEqual(pending.entries.map((entry) => entry.content), ["new", "memory"]); +}); + +test("expired context is pruned without affecting another session", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "session-1", source: "goal", id: "current", content: "expired", expiresAt: 100 }); + store.register({ sessionId: "session-2", source: "goal", id: "current", content: "active", expiresAt: 200 }); + + assert.equal(store.hasPending("session-1", 101), false); + assert.equal(store.getPending("session-2", 101).merged, "active"); +}); + +test("blank context is ignored", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "session-1", source: "hook", id: "blank", content: " \n " }); + assert.equal(store.hasPending("session-1"), false); +}); + +test("clearing a session does not disturb another session with a shared prefix", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "case", source: "hook", id: "one", content: "first" }); + store.register({ sessionId: "case:child", source: "hook", id: "two", content: "second" }); + + store.clear("case"); + + assert.equal(store.hasPending("case"), false); + assert.equal(store.getPending("case:child").merged, "second"); +}); + +test("context budgets retain higher-priority entries and cap merged prompt size", () => { + const store = new DynamicContextStore(); + for (let index = 0; index < 70; index += 1) { + store.register({ sessionId: "session-1", source: "bulk", id: String(index), content: `low-${index}`, priority: "low" }); + } + store.register({ sessionId: "session-1", source: "goal", id: "critical", content: "critical checkpoint", priority: "critical" }); + store.register({ sessionId: "session-2", source: "large", id: "one", content: "x".repeat(100_000) }); + store.register({ sessionId: "session-2", source: "large", id: "two", content: "y".repeat(100_000) }); + + const boundedEntries = store.getPending("session-1").entries; + assert.equal(boundedEntries.length, 64); + assert.equal(boundedEntries[0]?.content, "critical checkpoint"); + assert.ok(store.getPending("session-2").merged.length <= 65_536); + assert.ok(store.getPending("session-2").entries.every((entry) => entry.content.length <= 16_384)); +}); + +test("source and id tuples cannot collide through delimiter characters", () => { + const store = new DynamicContextStore(); + store.register({ sessionId: "session-1", source: "plugin:a", id: "b", content: "first" }); + store.register({ sessionId: "session-1", source: "plugin", id: "a:b", content: "second" }); + + assert.deepEqual(store.getPending("session-1").entries.map((entry) => entry.content), ["first", "second"]); +}); From dde78b91fcc78c61a7d193a03d2567d16e87a344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:14 +0800 Subject: [PATCH 02/12] feat(artifact): add validation contracts and runtime --- src/artifact/index.ts | 13 ++ src/artifact/protocol/types.ts | 48 ++++++ src/artifact/runtime/ArtifactContractStore.ts | 38 +++++ .../runtime/ArtifactValidationRuntime.ts | 90 +++++++++++ src/artifact/runtime/resolveArtifactPath.ts | 25 +++ .../validators/FileExistsValidator.ts | 44 ++++++ tests/artifact/artifact-validation.spec.ts | 142 ++++++++++++++++++ 7 files changed, 400 insertions(+) create mode 100644 src/artifact/index.ts create mode 100644 src/artifact/protocol/types.ts create mode 100644 src/artifact/runtime/ArtifactContractStore.ts create mode 100644 src/artifact/runtime/ArtifactValidationRuntime.ts create mode 100644 src/artifact/runtime/resolveArtifactPath.ts create mode 100644 src/artifact/validators/FileExistsValidator.ts create mode 100644 tests/artifact/artifact-validation.spec.ts diff --git a/src/artifact/index.ts b/src/artifact/index.ts new file mode 100644 index 00000000..7a266b9d --- /dev/null +++ b/src/artifact/index.ts @@ -0,0 +1,13 @@ +export type { + ArtifactContract, + ArtifactValidationIssue, + ArtifactValidationResult, + ArtifactValidationSummary, + ArtifactValidator, + ArtifactValidatorInput, + RegisteredArtifactContract, +} from "./protocol/types.js"; +export { ArtifactContractStore } from "./runtime/ArtifactContractStore.js"; +export { ArtifactValidationRuntime, formatArtifactCorrectionPrompt } from "./runtime/ArtifactValidationRuntime.js"; +export { resolveArtifactPath } from "./runtime/resolveArtifactPath.js"; +export { FileExistsValidator } from "./validators/FileExistsValidator.js"; diff --git a/src/artifact/protocol/types.ts b/src/artifact/protocol/types.ts new file mode 100644 index 00000000..43f59d6d --- /dev/null +++ b/src/artifact/protocol/types.ts @@ -0,0 +1,48 @@ +export type ArtifactContract = { + id: string; + path: string; + required?: boolean; + validatorIds?: readonly string[]; + expectedExtensions?: readonly string[]; + options?: Readonly>; + domainId?: string; +}; + +export type RegisteredArtifactContract = ArtifactContract & { + sourcePluginId: string; +}; + +export type ArtifactValidationIssue = { + code: string; + severity: "error" | "warning"; + message: string; + path?: string; + recoverable?: boolean; +}; + +export type ArtifactValidationResult = { + validatorId: string; + contractId: string; + status: "passed" | "failed" | "skipped" | "error"; + issues: readonly ArtifactValidationIssue[]; + evidence?: Readonly>; +}; + +export type ArtifactValidatorInput = { + contract: RegisteredArtifactContract; + workspaceRoot: string; + artifactPath: string; + sessionId: string; + turnId: string; + signal?: AbortSignal; +}; + +export interface ArtifactValidator { + readonly id: string; + validate(input: ArtifactValidatorInput): Promise; +} + +export type ArtifactValidationSummary = { + passed: boolean; + results: readonly ArtifactValidationResult[]; +}; diff --git a/src/artifact/runtime/ArtifactContractStore.ts b/src/artifact/runtime/ArtifactContractStore.ts new file mode 100644 index 00000000..a9136e12 --- /dev/null +++ b/src/artifact/runtime/ArtifactContractStore.ts @@ -0,0 +1,38 @@ +import type { ArtifactContract, RegisteredArtifactContract } from "../protocol/types.js"; + +const MAX_CONTRACTS_PER_SOURCE = 32; +const MAX_ID_LENGTH = 128; +const MAX_PATH_LENGTH = 1024; +const MAX_OPTIONS_BYTES = 32 * 1024; + +export class ArtifactContractStore { + private readonly sessions = new Map>(); + + register(sessionId: string, sourcePluginId: string, contracts: readonly ArtifactContract[]): void { + if (contracts.length > MAX_CONTRACTS_PER_SOURCE) { + throw new Error(`A plugin may register at most ${MAX_CONTRACTS_PER_SOURCE} artifact contracts at once.`); + } + for (const contract of contracts) validateContract(contract); + const session = this.sessions.get(sessionId) ?? new Map(); + for (const contract of contracts) { + session.set(`${sourcePluginId}:${contract.id}`, { ...contract, sourcePluginId }); + } + this.sessions.set(sessionId, session); + } + + list(sessionId: string): readonly RegisteredArtifactContract[] { + return [...(this.sessions.get(sessionId)?.values() ?? [])]; + } + + clear(sessionId: string): void { + this.sessions.delete(sessionId); + } +} + +function validateContract(contract: ArtifactContract): void { + if (!contract.id || contract.id.length > MAX_ID_LENGTH) throw new Error("Artifact contract id is missing or too long."); + if (!contract.path || contract.path.length > MAX_PATH_LENGTH) throw new Error("Artifact contract path is missing or too long."); + if (contract.options && Buffer.byteLength(JSON.stringify(contract.options), "utf8") > MAX_OPTIONS_BYTES) { + throw new Error("Artifact contract options exceed the size limit."); + } +} diff --git a/src/artifact/runtime/ArtifactValidationRuntime.ts b/src/artifact/runtime/ArtifactValidationRuntime.ts new file mode 100644 index 00000000..c0dd0351 --- /dev/null +++ b/src/artifact/runtime/ArtifactValidationRuntime.ts @@ -0,0 +1,90 @@ +import type { ArtifactValidationResult, ArtifactValidationSummary, ArtifactValidator } from "../protocol/types.js"; +import type { ArtifactContractStore } from "./ArtifactContractStore.js"; +import { resolveArtifactPath } from "./resolveArtifactPath.js"; + +export class ArtifactValidationRuntime { + private readonly validators: Map; + + constructor( + private readonly contracts: ArtifactContractStore, + validators: readonly ArtifactValidator[], + ) { + this.validators = new Map(); + for (const validator of validators) { + if (this.validators.has(validator.id)) { + throw new Error(`Duplicate artifact validator id: ${validator.id}`); + } + this.validators.set(validator.id, validator); + } + } + + async validate(input: { + sessionId: string; + turnId: string; + workspaceRoot: string; + signal?: AbortSignal; + }): Promise { + const results: ArtifactValidationResult[] = []; + let failedRequired = false; + for (const contract of this.contracts.list(input.sessionId)) { + let artifactPath: string; + try { + artifactPath = await resolveArtifactPath(input.workspaceRoot, contract.path); + } catch (error) { + const result: ArtifactValidationResult = { + validatorId: "core:path-boundary", + contractId: contract.id, + status: "failed", + issues: [{ code: "artifact_path_invalid", severity: "error", message: String(error), path: contract.path }], + }; + results.push(result); + if (contract.required !== false) failedRequired = true; + continue; + } + for (const validatorId of contract.validatorIds?.length ? contract.validatorIds : ["core:file-exists"]) { + const validator = this.validators.get(validatorId); + if (!validator) { + const result: ArtifactValidationResult = { + validatorId, + contractId: contract.id, + status: "error", + issues: [{ code: "artifact_validator_missing", severity: "error", message: `Validator ${validatorId} is not registered.` }], + }; + results.push(result); + if (contract.required !== false) failedRequired = true; + continue; + } + let result: ArtifactValidationResult; + try { + result = await validator.validate({ ...input, contract, artifactPath }); + } catch (error) { + result = { + validatorId, + contractId: contract.id, + status: "error", + issues: [{ + code: "artifact_validator_error", + severity: "error", + message: error instanceof Error ? error.message : String(error), + path: artifactPath, + }], + }; + } + results.push(result); + if (contract.required !== false && (result.status === "failed" || result.status === "error")) { + failedRequired = true; + } + } + } + return { passed: !failedRequired, results }; + } +} + +export function formatArtifactCorrectionPrompt(summary: ArtifactValidationSummary): string { + const issues = summary.results.flatMap((result) => result.issues.map((issue) => `- [${result.contractId}/${issue.code}] ${issue.message}${issue.path ? ` (${issue.path})` : ""}`)); + return [ + "Artifact validation failed. Fix the required deliverables before finishing.", + ...issues, + "Do not submit helper scripts as final artifacts. Re-run the relevant validators after writing the deliverables.", + ].join("\n"); +} diff --git a/src/artifact/runtime/resolveArtifactPath.ts b/src/artifact/runtime/resolveArtifactPath.ts new file mode 100644 index 00000000..ddf98b8e --- /dev/null +++ b/src/artifact/runtime/resolveArtifactPath.ts @@ -0,0 +1,25 @@ +import { lstat, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; + +export async function resolveArtifactPath(workspaceRoot: string, artifactPath: string): Promise { + if (isAbsolute(artifactPath)) throw new Error("Artifact paths must be workspace-relative."); + const root = await realpath(workspaceRoot); + const candidate = resolve(root, artifactPath); + assertWithin(root, candidate); + + try { + await lstat(candidate); + const canonical = await realpath(candidate); + assertWithin(root, canonical); + return canonical; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return candidate; + throw error; + } +} + +function assertWithin(root: string, candidate: string): void { + const rel = relative(root, candidate); + if (rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\"))) return; + throw new Error("Artifact path escapes the workspace root."); +} diff --git a/src/artifact/validators/FileExistsValidator.ts b/src/artifact/validators/FileExistsValidator.ts new file mode 100644 index 00000000..a5a75e31 --- /dev/null +++ b/src/artifact/validators/FileExistsValidator.ts @@ -0,0 +1,44 @@ +import { lstat } from "node:fs/promises"; +import { extname } from "node:path"; +import type { ArtifactValidator } from "../protocol/types.js"; + +export class FileExistsValidator implements ArtifactValidator { + readonly id = "core:file-exists"; + + async validate(input: Parameters[0]) { + try { + const info = await lstat(input.artifactPath); + if (!info.isFile()) return failed(this.id, input.contract.id, "artifact_not_file", "Expected artifact is not a regular file.", input.artifactPath); + const expected = input.contract.expectedExtensions?.map((value) => value.toLowerCase()); + if (expected?.length && !expected.includes(extname(input.artifactPath).toLowerCase())) { + return failed(this.id, input.contract.id, "artifact_extension_mismatch", `Expected one of: ${expected.join(", ")}.`, input.artifactPath); + } + return { + validatorId: this.id, + contractId: input.contract.id, + status: "passed" as const, + issues: [], + evidence: { bytes: info.size }, + }; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return failed(this.id, input.contract.id, "artifact_missing", "Required artifact does not exist.", input.artifactPath); + } + return { + validatorId: this.id, + contractId: input.contract.id, + status: "error" as const, + issues: [{ code: "artifact_validation_error", severity: "error" as const, message: String(error), path: input.artifactPath }], + }; + } + } +} + +function failed(validatorId: string, contractId: string, code: string, message: string, path: string) { + return { + validatorId, + contractId, + status: "failed" as const, + issues: [{ code, severity: "error" as const, message, path, recoverable: true }], + }; +} diff --git a/tests/artifact/artifact-validation.spec.ts b/tests/artifact/artifact-validation.spec.ts new file mode 100644 index 00000000..e00e0983 --- /dev/null +++ b/tests/artifact/artifact-validation.spec.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + ArtifactContractStore, + ArtifactValidationRuntime, + FileExistsValidator, + type ArtifactValidator, +} from "../../src/artifact/index.js"; + +test("validates a required artifact and keeps contracts isolated by session", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-artifact-")); + await writeFile(join(workspace, "report.xlsx"), "workbook"); + const store = new ArtifactContractStore(); + store.register("session-1", "legal:test", [{ + id: "complaint", + path: "report.xlsx", + expectedExtensions: [".xlsx"], + }]); + const runtime = new ArtifactValidationRuntime(store, [new FileExistsValidator()]); + + assert.equal((await runtime.validate({ sessionId: "session-1", turnId: "turn-1", workspaceRoot: workspace })).passed, true); + assert.deepEqual(store.list("session-2"), []); +}); + +test("missing or wrong-format artifacts fail with reviewer-readable issues", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-artifact-")); + await writeFile(join(workspace, "generate.py"), "print('helper')"); + const store = new ArtifactContractStore(); + store.register("session-1", "legal:test", [{ + id: "complaint", + path: "complaint.xlsx", + validatorIds: ["core:file-exists"], + expectedExtensions: [".xlsx"], + }]); + const runtime = new ArtifactValidationRuntime(store, [new FileExistsValidator()]); + const result = await runtime.validate({ sessionId: "session-1", turnId: "turn-1", workspaceRoot: workspace }); + assert.equal(result.passed, false); + assert.equal(result.results[0]?.issues[0]?.code, "artifact_missing"); +}); + +test("rejects traversal and symlink escapes before a domain validator runs", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-artifact-")); + const outside = await mkdtemp(join(tmpdir(), "pilotdeck-outside-")); + await writeFile(join(outside, "secret.txt"), "secret"); + await mkdir(join(workspace, "links")); + await symlink(join(outside, "secret.txt"), join(workspace, "links", "secret.txt")); + let calls = 0; + const validator: ArtifactValidator = { + id: "legal:complaint", + async validate(input) { + calls += 1; + return { validatorId: "legal:complaint", contractId: input.contract.id, status: "passed", issues: [] }; + }, + }; + const store = new ArtifactContractStore(); + store.register("session-1", "legal:test", [ + { id: "traversal", path: "../secret.txt", validatorIds: [validator.id] }, + { id: "symlink", path: "links/secret.txt", validatorIds: [validator.id] }, + ]); + const result = await new ArtifactValidationRuntime(store, [validator]).validate({ + sessionId: "session-1", + turnId: "turn-1", + workspaceRoot: workspace, + }); + assert.equal(result.passed, false); + assert.equal(calls, 0); + assert.deepEqual(result.results.map((entry) => entry.issues[0]?.code), ["artifact_path_invalid", "artifact_path_invalid"]); +}); + +test("legal-specific validator data stays in the plugin validator", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-artifact-")); + await writeFile(join(workspace, "complaint.xlsx"), "fake workbook"); + const legalValidator: ArtifactValidator = { + id: "legal:complaint-workbook", + async validate(input) { + assert.deepEqual(input.contract.options, { requiredColumns: ["事实", "证据定位"] }); + return { validatorId: this.id, contractId: input.contract.id, status: "passed", issues: [] }; + }, + }; + const store = new ArtifactContractStore(); + store.register("session-1", "legal:test", [{ + id: "complaint", + path: "complaint.xlsx", + validatorIds: [legalValidator.id], + options: { requiredColumns: ["事实", "证据定位"] }, + domainId: "legal", + }]); + const result = await new ArtifactValidationRuntime(store, [new FileExistsValidator(), legalValidator]).validate({ + sessionId: "session-1", + turnId: "turn-1", + workspaceRoot: workspace, + }); + assert.equal(result.passed, true); +}); + +test("validator exceptions become structured failures instead of escaping the runtime", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-artifact-")); + try { + await writeFile(join(workspace, "output.txt"), "content"); + const contracts = new ArtifactContractStore(); + contracts.register("session-1", "domain-plugin", [{ + id: "output", + path: "output.txt", + validatorIds: ["domain:throws"], + }]); + const runtime = new ArtifactValidationRuntime(contracts, [{ + id: "domain:throws", + async validate() { + throw new Error("validator unavailable"); + }, + }]); + + const result = await runtime.validate({ sessionId: "session-1", turnId: "turn-1", workspaceRoot: workspace }); + + assert.equal(result.passed, false); + assert.equal(result.results[0]?.status, "error"); + assert.equal(result.results[0]?.issues[0]?.code, "artifact_validator_error"); + assert.match(result.results[0]?.issues[0]?.message ?? "", /validator unavailable/); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("duplicate validator ids cannot override an existing validator", () => { + const contracts = new ArtifactContractStore(); + assert.throws(() => new ArtifactValidationRuntime(contracts, [ + new FileExistsValidator(), + { id: "core:file-exists", async validate() { throw new Error("must not run"); } }, + ]), /Duplicate artifact validator id/); +}); + +test("contract registration is atomic when one contract is invalid", () => { + const contracts = new ArtifactContractStore(); + assert.throws(() => contracts.register("session-1", "plugin", [ + { id: "valid", path: "valid.txt" }, + { id: "invalid", path: "" }, + ]), /path is missing/); + assert.deepEqual(contracts.list("session-1"), []); +}); From 34e8a53cd69562c611080ff4efb95711fbf6cd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:22 +0800 Subject: [PATCH 03/12] feat(lifecycle): support mutable model request effects --- src/extension/domain/DomainPlugin.ts | 22 +++++ src/extension/domain/DomainPluginRuntime.ts | 73 ++++++++++++++ src/extension/hooks/execution/HookRuntime.ts | 9 ++ .../hooks/execution/parseHookOutput.ts | 52 ++++++++++ src/extension/hooks/protocol/output.ts | 24 +++++ src/extension/index.ts | 5 + src/lifecycle/index.ts | 5 + src/lifecycle/protocol/effects.ts | 21 +++- src/lifecycle/runtime/LifecycleRuntime.ts | 96 +++++++++++++++---- .../runtime/applyPreModelRequestEffects.ts | 40 ++++++++ tests/context/dynamic-context-runtime.spec.ts | 95 ++++++++++++++++++ .../hooks/artifact-contract-output.spec.ts | 24 +++++ .../hooks/domain-plugin-runtime.spec.ts | 90 +++++++++++++++++ .../hooks/model-request-patch.spec.ts | 49 ++++++++++ .../pre-model-request-effects.spec.ts | 52 ++++++++++ 15 files changed, 638 insertions(+), 19 deletions(-) create mode 100644 src/extension/domain/DomainPlugin.ts create mode 100644 src/extension/domain/DomainPluginRuntime.ts create mode 100644 src/lifecycle/runtime/applyPreModelRequestEffects.ts create mode 100644 tests/context/dynamic-context-runtime.spec.ts create mode 100644 tests/extension/hooks/artifact-contract-output.spec.ts create mode 100644 tests/extension/hooks/domain-plugin-runtime.spec.ts create mode 100644 tests/extension/hooks/model-request-patch.spec.ts create mode 100644 tests/lifecycle/pre-model-request-effects.spec.ts diff --git a/src/extension/domain/DomainPlugin.ts b/src/extension/domain/DomainPlugin.ts new file mode 100644 index 00000000..6d45a811 --- /dev/null +++ b/src/extension/domain/DomainPlugin.ts @@ -0,0 +1,22 @@ +import type { ArtifactContract, ArtifactValidator } from "../../artifact/index.js"; + +export type DomainTaskInput = { + sessionId: string; + cwd: string; + prompt: string; +}; + +export type DomainActivation = { + domainId: string; + confidence: number; + skillIds?: readonly string[]; + contextPointer?: string; +}; + +export interface DomainPlugin { + readonly id: string; + readonly skillIds?: readonly string[]; + readonly validators?: readonly ArtifactValidator[]; + detectTask?(input: DomainTaskInput): Promise; + artifactContracts?(input: DomainTaskInput): Promise; +} diff --git a/src/extension/domain/DomainPluginRuntime.ts b/src/extension/domain/DomainPluginRuntime.ts new file mode 100644 index 00000000..5782e851 --- /dev/null +++ b/src/extension/domain/DomainPluginRuntime.ts @@ -0,0 +1,73 @@ +import type { ArtifactContractStore, ArtifactValidator } from "../../artifact/index.js"; +import type { DynamicContextStore } from "../../context/index.js"; +import type { PilotDeckLifecycleError } from "../../lifecycle/index.js"; +import type { DomainActivation, DomainPlugin, DomainTaskInput } from "./DomainPlugin.js"; + +export type DomainPluginActivationResult = { + activations: readonly DomainActivation[]; + errors: readonly PilotDeckLifecycleError[]; +}; + +/** Activates native domain plugins without moving domain knowledge into the agent core. */ +export class DomainPluginRuntime { + constructor(private readonly options: { + plugins: readonly DomainPlugin[]; + dynamicContext: DynamicContextStore; + artifactContracts: ArtifactContractStore; + }) {} + + validators(): readonly ArtifactValidator[] { + const validators = this.options.plugins.flatMap((plugin) => [...(plugin.validators ?? [])]); + const ids = new Set(); + for (const validator of validators) { + if (ids.has(validator.id)) throw new Error(`Duplicate domain artifact validator id: ${validator.id}`); + ids.add(validator.id); + } + return validators; + } + + async activate(input: DomainTaskInput): Promise { + const activations: DomainActivation[] = []; + const errors: PilotDeckLifecycleError[] = []; + for (const plugin of this.options.plugins) { + if (!plugin.detectTask) continue; + try { + const activation = await plugin.detectTask(input); + if (!activation || !Number.isFinite(activation.confidence) || activation.confidence <= 0) continue; + if (plugin.artifactContracts) { + const contracts = await plugin.artifactContracts(input); + this.options.artifactContracts.register(input.sessionId, plugin.id, contracts); + } + this.registerActivationContext(plugin, activation, input); + activations.push(activation); + } catch (error) { + errors.push({ + code: "hook_non_blocking_error", + hookName: plugin.id, + message: `Domain plugin ${plugin.id} failed to activate: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + return { activations, errors }; + } + + private registerActivationContext( + plugin: DomainPlugin, + activation: DomainActivation, + input: DomainTaskInput, + ): void { + const skillIds = [...new Set([...(plugin.skillIds ?? []), ...(activation.skillIds ?? [])])]; + const content = [ + `Domain plugin "${plugin.id}" activated for this task.`, + skillIds.length > 0 ? `Load and apply these project-scoped skills: ${skillIds.join(", ")}.` : undefined, + activation.contextPointer ? `Context pointer: ${activation.contextPointer}` : undefined, + ].filter((value): value is string => !!value).join("\n"); + this.options.dynamicContext.register({ + sessionId: input.sessionId, + source: `domain:${plugin.id}`, + id: activation.domainId, + content, + priority: "high", + }); + } +} diff --git a/src/extension/hooks/execution/HookRuntime.ts b/src/extension/hooks/execution/HookRuntime.ts index 66a8a011..bcc2da86 100644 --- a/src/extension/hooks/execution/HookRuntime.ts +++ b/src/extension/hooks/execution/HookRuntime.ts @@ -216,6 +216,15 @@ function effectsFromHookOutput(output: PilotDeckHookOutput, hookName: string): P if (specific.retry) { effects.push({ type: "retry_permission_denied" }); } + if (specific.modelRequestPatch) { + effects.push({ type: "model_request_patch", patch: specific.modelRequestPatch }); + } + if (specific.artifactContracts?.length) { + effects.push({ type: "artifact_contracts", sourcePluginId: hookName, contracts: specific.artifactContracts }); + } + for (const context of specific.dynamicContext ?? []) { + effects.push({ type: "additional_context", source: hookName, ...context }); + } } return effects; diff --git a/src/extension/hooks/execution/parseHookOutput.ts b/src/extension/hooks/execution/parseHookOutput.ts index a8f2b03d..86d9215a 100644 --- a/src/extension/hooks/execution/parseHookOutput.ts +++ b/src/extension/hooks/execution/parseHookOutput.ts @@ -62,9 +62,61 @@ function parseSpecificOutput(value: unknown): PilotDeckHookSpecificOutput | unde decision: parsePermissionRequestDecision(record.decision), retry: booleanOrUndefined(record.retry), worktreePath: stringOrUndefined(record.worktreePath), + modelRequestPatch: parseModelRequestPatch(record.modelRequestPatch), + artifactContracts: parseArtifactContracts(record.artifactContracts), + dynamicContext: parseDynamicContext(record.dynamicContext), }; } +function parseDynamicContext(value: unknown): PilotDeckHookSpecificOutput["dynamicContext"] { + if (!Array.isArray(value)) return undefined; + const entries = value.slice(0, 64).flatMap((item) => { + if (!isRecord(item) || typeof item.id !== "string" || typeof item.content !== "string") return []; + const id = item.id.trim().slice(0, 128); + const content = item.content.trim(); + if (!id || !content) return []; + const priority: "critical" | "high" | "normal" | "low" | undefined = item.priority === "critical" || item.priority === "high" + || item.priority === "normal" || item.priority === "low" + ? item.priority + : undefined; + const ttlMs = typeof item.ttlMs === "number" && Number.isFinite(item.ttlMs) && item.ttlMs > 0 + ? Math.min(Math.floor(item.ttlMs), 24 * 60 * 60 * 1_000) + : undefined; + return [{ id, content, priority, ttlMs }]; + }); + return entries.length > 0 ? entries : undefined; +} + +function parseArtifactContracts(value: unknown): PilotDeckHookSpecificOutput["artifactContracts"] { + if (!Array.isArray(value)) return undefined; + const contracts = value.slice(0, 32).flatMap((item) => { + if (!isRecord(item) || typeof item.id !== "string" || typeof item.path !== "string") return []; + return [{ + id: item.id, + path: item.path, + required: typeof item.required === "boolean" ? item.required : undefined, + validatorIds: Array.isArray(item.validatorIds) ? item.validatorIds.filter((entry): entry is string => typeof entry === "string") : undefined, + expectedExtensions: Array.isArray(item.expectedExtensions) ? item.expectedExtensions.filter((entry): entry is string => typeof entry === "string") : undefined, + options: isRecord(item.options) ? item.options : undefined, + domainId: typeof item.domainId === "string" ? item.domainId : undefined, + }]; + }); + return contracts.length > 0 ? contracts : undefined; +} + +function parseModelRequestPatch(value: unknown): PilotDeckHookSpecificOutput["modelRequestPatch"] { + if (!isRecord(value)) return undefined; + const patch: NonNullable = {}; + if (typeof value.provider === "string" && value.provider.trim().length > 0) patch.provider = value.provider; + if (typeof value.model === "string" && value.model.trim().length > 0) patch.model = value.model; + if (typeof value.maxOutputTokens === "number" && Number.isInteger(value.maxOutputTokens) && value.maxOutputTokens > 0) { + patch.maxOutputTokens = value.maxOutputTokens; + } + if (typeof value.temperature === "number" && Number.isFinite(value.temperature)) patch.temperature = value.temperature; + if (isRecord(value.metadata)) patch.metadata = value.metadata; + return Object.keys(patch).length > 0 ? patch : undefined; +} + function parsePermissionDecision(value: unknown): PilotDeckHookSpecificOutput["permissionDecision"] { return value === "allow" || value === "deny" || value === "ask" || value === "passthrough" ? value : undefined; } diff --git a/src/extension/hooks/protocol/output.ts b/src/extension/hooks/protocol/output.ts index 1c132696..4075e34c 100644 --- a/src/extension/hooks/protocol/output.ts +++ b/src/extension/hooks/protocol/output.ts @@ -22,6 +22,30 @@ export type PilotDeckHookSpecificOutput = { decision?: PilotDeckPermissionHookDecision; retry?: boolean; worktreePath?: string; + /** Restricted PreModelRequest patch; messages and tools cannot be replaced. */ + modelRequestPatch?: { + provider?: string; + model?: string; + maxOutputTokens?: number; + temperature?: number; + metadata?: Record; + }; + artifactContracts?: Array<{ + id: string; + path: string; + required?: boolean; + validatorIds?: string[]; + expectedExtensions?: string[]; + options?: Record; + domainId?: string; + }>; + /** Session-scoped context consumed by the next model request. */ + dynamicContext?: Array<{ + id: string; + content: string; + priority?: "critical" | "high" | "normal" | "low"; + ttlMs?: number; + }>; }; export type PilotDeckHookSyncOutput = { diff --git a/src/extension/index.ts b/src/extension/index.ts index 4e65d5e5..87efb72b 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -35,6 +35,11 @@ export { AsyncHookRegistry, type AsyncHookResponse, type PendingAsyncHook } from export { HookExecutionEventBus, type PilotDeckHookExecutionEvent } from "./hooks/events/HookExecutionEventBus.js"; export type { PilotDeckPluginManifest } from "./plugins/protocol/manifest.js"; +export type { DomainActivation, DomainPlugin, DomainTaskInput } from "./domain/DomainPlugin.js"; +export { + DomainPluginRuntime, + type DomainPluginActivationResult, +} from "./domain/DomainPluginRuntime.js"; export type { PilotDeckMarketplaceReference } from "./plugins/protocol/manifest.js"; export type { PilotDeckLoadedPlugin, PilotDeckPluginSourceKind } from "./plugins/protocol/plugin.js"; export { resolveMarketplaceReference, type PilotDeckMarketplaceResolution, type PilotDeckPluginMarketplaceStatus } from "./plugins/protocol/marketplace.js"; diff --git a/src/lifecycle/index.ts b/src/lifecycle/index.ts index 81ba770f..c7f9d5c8 100644 --- a/src/lifecycle/index.ts +++ b/src/lifecycle/index.ts @@ -3,10 +3,15 @@ export type { PilotDeckHookEffect, PilotDeckHookPermissionBehavior, PilotDeckLifecycleError, + PilotDeckModelRequestPatch, PilotDeckPermissionRequestResult, } from "./protocol/effects.js"; export type { LifecycleDispatchInput, LifecycleDispatchResult } from "./protocol/payloads.js"; export { emptyLifecycleDispatchResult } from "./protocol/payloads.js"; export { PilotDeckLifecycleRuntimeError } from "./protocol/errors.js"; export { LifecycleRuntime, NullLifecycleRuntime } from "./runtime/LifecycleRuntime.js"; +export { + applyPreModelRequestEffects, + type PreModelRequestTransformResult, +} from "./runtime/applyPreModelRequestEffects.js"; export type { LifecycleObserver } from "./runtime/LifecycleObserver.js"; diff --git a/src/lifecycle/protocol/effects.ts b/src/lifecycle/protocol/effects.ts index 89323255..adc7b43a 100644 --- a/src/lifecycle/protocol/effects.ts +++ b/src/lifecycle/protocol/effects.ts @@ -1,5 +1,15 @@ export type PilotDeckHookPermissionBehavior = "allow" | "deny" | "ask" | "passthrough"; +export type PilotDeckModelRequestPatch = { + provider?: string; + model?: string; + maxOutputTokens?: number; + temperature?: number; + metadata?: Record; +}; + +import type { ArtifactContract } from "../../artifact/index.js"; + export type PilotDeckPermissionRequestResult = | { behavior: "allow"; @@ -13,8 +23,17 @@ export type PilotDeckPermissionRequestResult = }; export type PilotDeckHookEffect = - | { type: "additional_context"; content: string; source: string } + | { + type: "additional_context"; + content: string; + source: string; + id?: string; + priority?: "critical" | "high" | "normal" | "low"; + ttlMs?: number; + } | { type: "system_message"; content: string } + | { type: "model_request_patch"; patch: PilotDeckModelRequestPatch } + | { type: "artifact_contracts"; sourcePluginId: string; contracts: readonly ArtifactContract[] } | { type: "block"; reason: string; stopReason?: string } | { type: "permission_decision"; behavior: PilotDeckHookPermissionBehavior; reason?: string } | { type: "updated_tool_input"; input: Record } diff --git a/src/lifecycle/runtime/LifecycleRuntime.ts b/src/lifecycle/runtime/LifecycleRuntime.ts index 74e2e395..ffe22eff 100644 --- a/src/lifecycle/runtime/LifecycleRuntime.ts +++ b/src/lifecycle/runtime/LifecycleRuntime.ts @@ -1,30 +1,90 @@ import type { CanonicalMessage } from "../../model/index.js"; +import type { DynamicContextStore } from "../../context/dynamic/DynamicContextStore.js"; +import type { ArtifactContractStore } from "../../artifact/index.js"; +import type { DomainPluginRuntime } from "../../extension/index.js"; import { HookRuntime } from "../../extension/hooks/execution/HookRuntime.js"; import { createHookInput } from "../../extension/hooks/protocol/input.js"; import type { LifecycleDispatchInput, LifecycleDispatchResult } from "../protocol/payloads.js"; import { emptyLifecycleDispatchResult } from "../protocol/payloads.js"; export class LifecycleRuntime { - constructor(private readonly hooks = new HookRuntime()) {} + constructor( + private readonly hooks = new HookRuntime(), + private readonly dynamicContext?: DynamicContextStore, + private readonly artifactContracts?: ArtifactContractStore, + private readonly domainPlugins?: DomainPluginRuntime, + private readonly now: () => number = Date.now, + ) {} async dispatch(input: LifecycleDispatchInput): Promise { - const hookInput = createHookInput(input.event, input.baseInput, input.payload); - const hookResult = await this.hooks.run({ - event: input.event, - hookInput, - matchQuery: input.matchQuery, - cwd: input.baseInput.cwd, - env: input.env, - signal: input.signal, - }); - - return { - effects: hookResult.effects, - messages: createMessagesFromEffects(hookResult.effects), - events: hookResult.events, - blockingErrors: hookResult.blockingErrors, - nonBlockingErrors: hookResult.nonBlockingErrors, - }; + try { + const domainResult = input.event === "UserPromptSubmit" + && input.payload?.internal !== true + && typeof input.payload?.prompt === "string" + ? await this.domainPlugins?.activate({ + sessionId: input.baseInput.sessionId, + cwd: input.baseInput.cwd, + prompt: input.payload.prompt, + }) + : undefined; + const hookInput = createHookInput(input.event, input.baseInput, input.payload); + const hookResult = await this.hooks.run({ + event: input.event, + hookInput, + matchQuery: input.matchQuery, + cwd: input.baseInput.cwd, + env: input.env, + signal: input.signal, + }); + + const isPreModelRequest = input.event === "PreModelRequest"; + if (!isPreModelRequest) this.registerDynamicContext(input, hookResult.effects); + this.registerArtifactContracts(input, hookResult.effects); + + return { + effects: hookResult.effects, + messages: this.dynamicContext && !isPreModelRequest ? [] : createMessagesFromEffects(hookResult.effects), + events: hookResult.events, + blockingErrors: hookResult.blockingErrors, + nonBlockingErrors: [...hookResult.nonBlockingErrors, ...(domainResult?.errors ?? [])], + }; + } finally { + if (input.event === "SessionEnd") { + this.dynamicContext?.clear(input.baseInput.sessionId); + this.artifactContracts?.clear(input.baseInput.sessionId); + } + } + } + + private registerArtifactContracts( + input: LifecycleDispatchInput, + effects: LifecycleDispatchResult["effects"], + ): void { + if (!this.artifactContracts) return; + for (const effect of effects) { + if (effect.type !== "artifact_contracts") continue; + this.artifactContracts.register(input.baseInput.sessionId, effect.sourcePluginId, effect.contracts); + } + } + + private registerDynamicContext( + input: LifecycleDispatchInput, + effects: LifecycleDispatchResult["effects"], + ): void { + if (!this.dynamicContext) return; + let index = 0; + for (const effect of effects) { + if (effect.type !== "additional_context") continue; + this.dynamicContext.register({ + sessionId: input.baseInput.sessionId, + turnId: typeof input.payload?.turnId === "string" ? input.payload.turnId : undefined, + source: effect.source, + id: `${input.event}:${effect.id ?? index++}`, + content: effect.content, + priority: effect.priority, + expiresAt: effect.ttlMs === undefined ? undefined : this.now() + effect.ttlMs, + }); + } } } diff --git a/src/lifecycle/runtime/applyPreModelRequestEffects.ts b/src/lifecycle/runtime/applyPreModelRequestEffects.ts new file mode 100644 index 00000000..bda6920e --- /dev/null +++ b/src/lifecycle/runtime/applyPreModelRequestEffects.ts @@ -0,0 +1,40 @@ +import type { CanonicalModelRequest } from "../../model/index.js"; +import type { LifecycleDispatchResult } from "../protocol/payloads.js"; + +export type PreModelRequestTransformResult = + | { type: "blocked"; reason: string; stopReason?: string } + | { type: "ready"; request: CanonicalModelRequest }; + +/** Applies the deliberately restricted set of mutations allowed immediately before routing. */ +export function applyPreModelRequestEffects( + request: CanonicalModelRequest, + result: LifecycleDispatchResult, +): PreModelRequestTransformResult { + const blocked = result.effects.find((effect) => effect.type === "block"); + if (blocked) return { type: "blocked", reason: blocked.reason, stopReason: blocked.stopReason }; + + let next: CanonicalModelRequest = result.messages.length > 0 + ? { ...request, messages: [...request.messages, ...result.messages] } + : request; + + for (const effect of result.effects) { + if (effect.type === "system_message") { + next = { + ...next, + systemPrompt: [next.systemPrompt, effect.content].filter(Boolean).join("\n\n"), + }; + continue; + } + if (effect.type === "model_request_patch") { + next = { + ...next, + ...effect.patch, + metadata: effect.patch.metadata + ? { ...(next.metadata ?? {}), ...effect.patch.metadata } + : next.metadata, + }; + } + } + + return { type: "ready", request: next }; +} diff --git a/tests/context/dynamic-context-runtime.spec.ts b/tests/context/dynamic-context-runtime.spec.ts new file mode 100644 index 00000000..44716c1c --- /dev/null +++ b/tests/context/dynamic-context-runtime.spec.ts @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DefaultContextRuntime } from "../../src/context/DefaultContextRuntime.js"; +import { DynamicContextStore } from "../../src/context/dynamic/DynamicContextStore.js"; +import { ArtifactContractStore } from "../../src/artifact/index.js"; +import { LifecycleRuntime } from "../../src/lifecycle/runtime/LifecycleRuntime.js"; +import type { HookRuntimeRunInput, HookRuntimeRunResult } from "../../src/extension/hooks/execution/HookRuntime.js"; + +test("lifecycle context is injected as a transient model-only message", async () => { + const store = new DynamicContextStore(); + const hooks = { + async run(_input: HookRuntimeRunInput): Promise { + return { + effects: [{ type: "additional_context", source: "legal:test", content: "Review every exhibit." }], + events: [], + blockingErrors: [], + nonBlockingErrors: [], + }; + }, + }; + const lifecycle = new LifecycleRuntime(hooks as never, store); + + const dispatched = await lifecycle.dispatch({ + event: "UserPromptSubmit", + baseInput: { sessionId: "session-1", transcriptPath: "", cwd: "/tmp" }, + }); + assert.deepEqual(dispatched.messages, []); + assert.equal(store.hasPending("session-1"), true); + + const context = new DefaultContextRuntime({ dynamicContext: store }); + const prepared = await context.prepareForModel({ + sessionId: "session-1", + turnId: "turn-1", + cwd: "/tmp", + provider: "test", + model: "model", + permissionMode: "default", + additionalWorkingDirectories: [], + messages: [{ role: "user", content: [{ type: "text", text: "Analyze the file." }] }], + tools: [], + }); + + assert.equal(prepared.messages.length, 2); + assert.deepEqual(prepared.messages.at(-1)?.metadata, { + synthetic: true, + transient: true, + transientId: "dynamic-context:session-1:turn-1", + purpose: "dynamic_context", + }); + const injected = prepared.messages.at(-1)?.content[0]; + assert.match(injected?.type === "text" ? injected.text : "", /Review every exhibit/); + assert.equal(store.hasPending("session-1"), true); + context.commitPreparedContext({ sessionId: "session-1" }); + assert.equal(store.hasPending("session-1"), false); + assert.equal(prepared.diagnostics.some((item) => item.code === "dynamic_context_injected"), true); +}); + +test("lifecycle without a dynamic store preserves legacy hook messages", async () => { + const hooks = { + async run(): Promise { + return { + effects: [{ type: "additional_context", source: "legacy", content: "legacy context" }], + events: [], + blockingErrors: [], + nonBlockingErrors: [], + }; + }, + }; + const lifecycle = new LifecycleRuntime(hooks as never); + const dispatched = await lifecycle.dispatch({ + event: "UserPromptSubmit", + baseInput: { sessionId: "session-1", transcriptPath: "", cwd: "/tmp" }, + }); + assert.equal(dispatched.messages.length, 1); +}); + +test("SessionEnd clears session-scoped runtime state even when a hook throws", async () => { + const dynamicContext = new DynamicContextStore(); + dynamicContext.register({ sessionId: "session-1", source: "hook", id: "pending", content: "stale" }); + const artifactContracts = new ArtifactContractStore(); + artifactContracts.register("session-1", "plugin", [{ id: "output", path: "output.txt" }]); + const lifecycle = new LifecycleRuntime({ + async run() { + throw new Error("hook crashed"); + }, + } as never, dynamicContext, artifactContracts); + + await assert.rejects(lifecycle.dispatch({ + event: "SessionEnd", + baseInput: { sessionId: "session-1", transcriptPath: "", cwd: "/tmp" }, + }), /hook crashed/); + + assert.equal(dynamicContext.hasPending("session-1"), false); + assert.deepEqual(artifactContracts.list("session-1"), []); +}); diff --git a/tests/extension/hooks/artifact-contract-output.spec.ts b/tests/extension/hooks/artifact-contract-output.spec.ts new file mode 100644 index 00000000..bc1bf53e --- /dev/null +++ b/tests/extension/hooks/artifact-contract-output.spec.ts @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseHookOutput } from "../../../src/extension/hooks/execution/parseHookOutput.js"; + +test("parses declarative artifact contracts without domain-specific knowledge", () => { + const output = parseHookOutput(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + artifactContracts: [{ + id: "complaint", + path: "deliverables/complaint.xlsx", + required: true, + validatorIds: ["legal:complaint-workbook"], + expectedExtensions: [".xlsx"], + domainId: "legal", + options: { schema: "complaint-v1" }, + }], + }, + })); + assert.equal(output.type, "sync"); + if (output.type !== "sync") return; + assert.equal(output.specific?.artifactContracts?.[0]?.domainId, "legal"); + assert.deepEqual(output.specific?.artifactContracts?.[0]?.validatorIds, ["legal:complaint-workbook"]); +}); diff --git a/tests/extension/hooks/domain-plugin-runtime.spec.ts b/tests/extension/hooks/domain-plugin-runtime.spec.ts new file mode 100644 index 00000000..bed86f0f --- /dev/null +++ b/tests/extension/hooks/domain-plugin-runtime.spec.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + ArtifactContractStore, + type ArtifactValidator, +} from "../../../src/artifact/index.js"; +import { DynamicContextStore } from "../../../src/context/index.js"; +import { + DomainPluginRuntime, + type DomainPlugin, +} from "../../../src/extension/index.js"; +import { LifecycleRuntime } from "../../../src/lifecycle/index.js"; + +test("an active domain plugin contributes only generic runtime contracts", async () => { + const dynamicContext = new DynamicContextStore(); + const artifactContracts = new ArtifactContractStore(); + const validator: ArtifactValidator = { + id: "domain:structured-deliverable", + async validate(input) { + return { validatorId: this.id, contractId: input.contract.id, status: "passed", issues: [] }; + }, + }; + const plugin: DomainPlugin = { + id: "project-domain", + skillIds: ["domain-analysis"], + validators: [validator], + async detectTask(input) { + return input.prompt.includes("specialized") + ? { domainId: "specialized-work", confidence: 0.95, contextPointer: "references/checklist.md" } + : null; + }, + async artifactContracts() { + return [{ + id: "deliverable", + path: "deliverable.bin", + validatorIds: [validator.id], + domainId: "specialized-work", + }]; + }, + }; + const runtime = new DomainPluginRuntime({ plugins: [plugin], dynamicContext, artifactContracts }); + const lifecycle = new LifecycleRuntime({ + async run() { + return { effects: [], events: [], blockingErrors: [], nonBlockingErrors: [] }; + }, + } as never, dynamicContext, artifactContracts, runtime); + + const result = await lifecycle.dispatch({ + event: "UserPromptSubmit", + baseInput: { sessionId: "session-1", transcriptPath: "", cwd: "/workspace" }, + payload: { prompt: "perform specialized analysis", turnId: "turn-1" }, + }); + + assert.deepEqual(result.nonBlockingErrors, []); + assert.equal(runtime.validators()[0]?.id, validator.id); + assert.match(dynamicContext.getPending("session-1").merged, /domain-analysis/); + assert.match(dynamicContext.getPending("session-1").merged, /references\/checklist\.md/); + assert.equal(artifactContracts.list("session-1")[0]?.domainId, "specialized-work"); +}); + +test("internal prompts do not reactivate domain detection", async () => { + let detections = 0; + const dynamicContext = new DynamicContextStore(); + const artifactContracts = new ArtifactContractStore(); + const runtime = new DomainPluginRuntime({ + plugins: [{ + id: "domain", + async detectTask() { + detections += 1; + return { domainId: "domain", confidence: 1 }; + }, + }], + dynamicContext, + artifactContracts, + }); + const lifecycle = new LifecycleRuntime({ + async run() { + return { effects: [], events: [], blockingErrors: [], nonBlockingErrors: [] }; + }, + } as never, dynamicContext, artifactContracts, runtime); + + await lifecycle.dispatch({ + event: "UserPromptSubmit", + baseInput: { sessionId: "session-1", transcriptPath: "", cwd: "/workspace" }, + payload: { prompt: "wake up", internal: true }, + }); + + assert.equal(detections, 0); + assert.equal(dynamicContext.hasPending("session-1"), false); +}); diff --git a/tests/extension/hooks/model-request-patch.spec.ts b/tests/extension/hooks/model-request-patch.spec.ts new file mode 100644 index 00000000..3bdc6b11 --- /dev/null +++ b/tests/extension/hooks/model-request-patch.spec.ts @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseHookOutput } from "../../../src/extension/hooks/execution/parseHookOutput.js"; + +test("parses only the supported model request patch fields", () => { + const output = parseHookOutput(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreModelRequest", + modelRequestPatch: { + provider: "provider", + model: "model", + maxOutputTokens: 4096, + temperature: 0.2, + metadata: { domain: "legal" }, + messages: [{ role: "user", content: "forbidden" }], + tools: [], + }, + }, + })); + assert.equal(output.type, "sync"); + if (output.type !== "sync") return; + assert.deepEqual(output.specific?.modelRequestPatch, { + provider: "provider", + model: "model", + maxOutputTokens: 4096, + temperature: 0.2, + metadata: { domain: "legal" }, + }); +}); + +test("parses bounded dynamic context controls for hook-driven injection", () => { + const output = parseHookOutput(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + dynamicContext: [ + { id: "goal", content: "Goal checkpoint 3/7", priority: "critical", ttlMs: 2_000 }, + { id: "invalid", content: "ignored priority", priority: "urgent", ttlMs: 999_999_999 }, + { id: "blank", content: " " }, + ], + }, + })); + + assert.equal(output.type, "sync"); + if (output.type !== "sync") return; + assert.deepEqual(output.specific?.dynamicContext, [ + { id: "goal", content: "Goal checkpoint 3/7", priority: "critical", ttlMs: 2_000 }, + { id: "invalid", content: "ignored priority", priority: undefined, ttlMs: 86_400_000 }, + ]); +}); diff --git a/tests/lifecycle/pre-model-request-effects.spec.ts b/tests/lifecycle/pre-model-request-effects.spec.ts new file mode 100644 index 00000000..fb4eecd7 --- /dev/null +++ b/tests/lifecycle/pre-model-request-effects.spec.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyPreModelRequestEffects } from "../../src/lifecycle/runtime/applyPreModelRequestEffects.js"; +import type { LifecycleDispatchResult } from "../../src/lifecycle/protocol/payloads.js"; +import type { CanonicalModelRequest } from "../../src/model/index.js"; + +const request: CanonicalModelRequest = { + provider: "base-provider", + model: "base-model", + messages: [{ role: "user", content: [{ type: "text", text: "original" }] }], + systemPrompt: "base system", + maxOutputTokens: 1000, +}; + +function result(overrides: Partial): LifecycleDispatchResult { + return { + effects: [], + messages: [], + events: [], + blockingErrors: [], + nonBlockingErrors: [], + ...overrides, + }; +} + +test("applies context, system messages, and restricted model request patches", () => { + const transformed = applyPreModelRequestEffects(request, result({ + messages: [{ role: "user", content: [{ type: "text", text: "dynamic" }], metadata: { synthetic: true } }], + effects: [ + { type: "system_message", content: "legal system addendum" }, + { type: "model_request_patch", patch: { model: "review-model", maxOutputTokens: 2000, metadata: { source: "legal" } } }, + ], + })); + + assert.equal(transformed.type, "ready"); + if (transformed.type !== "ready") return; + assert.equal(transformed.request.model, "review-model"); + assert.equal(transformed.request.maxOutputTokens, 2000); + assert.equal(transformed.request.messages.length, 2); + assert.equal(transformed.request.systemPrompt, "base system\n\nlegal system addendum"); + assert.deepEqual(transformed.request.metadata, { source: "legal" }); +}); + +test("blocks before any request mutation is used", () => { + const transformed = applyPreModelRequestEffects(request, result({ + effects: [ + { type: "model_request_patch", patch: { model: "unused" } }, + { type: "block", reason: "validator rejected request" }, + ], + })); + assert.deepEqual(transformed, { type: "blocked", reason: "validator rejected request", stopReason: undefined }); +}); From c8d816f2fecc4347fafda69979bc164a57cfb296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:28 +0800 Subject: [PATCH 04/12] feat(gateway): arbitrate internal prompt dispatch --- src/gateway/SessionRouter.ts | 57 +++++ src/gateway/client/InProcessGateway.ts | 55 ++++- src/gateway/index.ts | 14 +- src/gateway/internal/PromptDispatchGate.ts | 209 ++++++++++++++++++ src/gateway/server/GatewayWsConnection.ts | 13 +- ...ternal-prompt-dispatch-integration.spec.ts | 76 +++++++ tests/gateway/prompt-dispatch-gate.spec.ts | 157 +++++++++++++ .../public-submit-turn-boundary.spec.ts | 19 ++ tests/gateway/session-router-idle.spec.ts | 37 ++++ 9 files changed, 631 insertions(+), 6 deletions(-) create mode 100644 src/gateway/internal/PromptDispatchGate.ts create mode 100644 tests/gateway/internal-prompt-dispatch-integration.spec.ts create mode 100644 tests/gateway/prompt-dispatch-gate.spec.ts create mode 100644 tests/gateway/public-submit-turn-boundary.spec.ts create mode 100644 tests/gateway/session-router-idle.spec.ts diff --git a/src/gateway/SessionRouter.ts b/src/gateway/SessionRouter.ts index 6d49c8e1..6d89d74a 100644 --- a/src/gateway/SessionRouter.ts +++ b/src/gateway/SessionRouter.ts @@ -54,6 +54,7 @@ export class SessionRouter { private readonly now: () => Date; private readonly idleSweepTimer?: ReturnType; private isShutdown = false; + private readonly idleWaiters = new Map void>>(); constructor(private readonly options: SessionRouterOptions) { this.idleSessionTimeoutMs = options.idleSessionTimeoutMs ?? DEFAULT_IDLE_SESSION_TIMEOUT_MS; @@ -106,6 +107,48 @@ export class SessionRouter { if (record) { record.lastUsedAt = this.nowMs(); } + if (!this.inFlightTurns.has(sessionKey)) this.resolveIdleWaiters(sessionKey); + } + + isTurnInFlight(sessionKey: string): boolean { + return this.inFlightTurns.has(sessionKey); + } + + async waitForIdle( + sessionKey: string, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise { + if (!this.inFlightTurns.has(sessionKey)) return; + if (options.signal?.aborted) throw abortError(options.signal.reason); + + await new Promise((resolve, reject) => { + const waiters = this.idleWaiters.get(sessionKey) ?? new Set<() => void>(); + let timeout: NodeJS.Timeout | undefined; + const cleanup = () => { + waiters.delete(onIdle); + if (waiters.size === 0) this.idleWaiters.delete(sessionKey); + options.signal?.removeEventListener("abort", onAbort); + if (timeout) clearTimeout(timeout); + }; + const onIdle = () => { + cleanup(); + resolve(); + }; + const onAbort = () => { + cleanup(); + reject(abortError(options.signal?.reason)); + }; + waiters.add(onIdle); + this.idleWaiters.set(sessionKey, waiters); + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.timeoutMs !== undefined) { + timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for session ${sessionKey} to become idle.`)); + }, options.timeoutMs); + } + if (!this.inFlightTurns.has(sessionKey)) onIdle(); + }); } async abort(sessionKey: string, reason?: string): Promise { @@ -189,6 +232,14 @@ export class SessionRouter { } this.sessions.clear(); this.inFlightTurns.clear(); + for (const sessionKey of this.idleWaiters.keys()) this.resolveIdleWaiters(sessionKey); + } + + private resolveIdleWaiters(sessionKey: string): void { + const waiters = this.idleWaiters.get(sessionKey); + if (!waiters) return; + this.idleWaiters.delete(sessionKey); + for (const resolve of [...waiters]) resolve(); } /** @@ -236,6 +287,12 @@ export class SessionRouter { } } +function abortError(reason: unknown): Error { + const error = new Error(typeof reason === "string" ? reason : "Operation aborted."); + error.name = "AbortError"; + return error; +} + function snapshotEvictedSession(sessionKey: string, record: SessionRecord): SessionEvictionSnapshot { let messageCount: number | undefined; try { diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index d15e9801..86f3142b 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -85,6 +85,7 @@ import type { import { createVisibleErrorStatusDetail } from "../../status/agentStatus.js"; import type { TelemetryClient } from "../../telemetry/index.js"; import type { TelemetryExecutionKind, TelemetryModule } from "../../telemetry/index.js"; +import { PromptDispatchGate } from "../internal/PromptDispatchGate.js"; const PLAN_COMMAND_USAGE = "用法:/plan <任务>\n例如:/plan 设计一个新功能"; const MAX_GATEWAY_TOOL_RESULT_PREVIEW_CHARS = 20_000; @@ -164,6 +165,12 @@ export type InProcessGatewayOptions = { telemetry?: TelemetryClient; }; +export type InProcessGatewaySubmitTurnInput = GatewaySubmitTurnInput & { + /** Trusted in-process callers use this to mark synthetic wake-up prompts. */ + origin?: { kind: "user" } | { kind: "internal"; source: string }; + signal?: AbortSignal; +}; + const ACTIVE_TURN_EVENT_LIMIT = 500; const ACTIVE_TURN_BYTE_LIMIT = 256 * 1024; @@ -204,12 +211,45 @@ export class InProcessGateway implements Gateway { * while `inFlightTurns` was still populated, racing the next submit. */ private readonly turnCompletions = new Map>(); + private readonly internalPromptDispatcher: PromptDispatchGate; constructor( private readonly router: SessionRouter, private readonly options: InProcessGatewayOptions = {}, ) { this.now = options.now ?? (() => new Date()); this.uuid = options.uuid ?? randomUUID; + this.internalPromptDispatcher = new PromptDispatchGate({ + isSessionBusy: (sessionKey) => this.router.isTurnInFlight(sessionKey), + waitForSessionIdle: (sessionKey, waitOptions) => this.router.waitForIdle(sessionKey, waitOptions), + runInternalTurn: async (turn) => { + let finishReason = "unknown"; + for await (const event of this.submitTurn({ + sessionKey: turn.sessionKey, + channelKey: turn.channelKey, + projectKey: turn.projectKey, + message: turn.prompt, + runId: turn.runId, + timeoutMs: turn.timeoutMs, + signal: turn.signal, + origin: { kind: "internal", source: turn.source }, + canPrompt: false, + })) { + if (event.type === "error" && event.code === "session_busy") return { status: "busy" }; + if (event.type === "error" && event.code === "turn_timeout") return { status: "timed_out" }; + if (event.type === "error") return { status: "failed", error: new Error(event.message) }; + if (event.type === "turn_completed") finishReason = event.finishReason; + } + return { status: "completed", runId: turn.runId, finishReason }; + }, + }, { now: () => this.now().getTime(), uuid: this.uuid }); + } + + getInternalPromptDispatcher(): PromptDispatchGate { + return this.internalPromptDispatcher; + } + + dispose(): void { + this.internalPromptDispatcher.dispose(); } /** @@ -273,7 +313,7 @@ export class InProcessGateway implements Gateway { this.emitForSession(detail.sessionId, event); } - async *submitTurn(input: GatewaySubmitTurnInput): AsyncIterable { + async *submitTurn(input: InProcessGatewaySubmitTurnInput): AsyncIterable { const plannedInput = normalizePlanCommandInput(input); if (!plannedInput) { yield { @@ -374,6 +414,11 @@ export class InProcessGateway implements Gateway { projectKey: input.projectKey, channelKey: input.channelKey, }); + if (input.signal?.aborted) { + throw input.signal.reason instanceof Error ? input.signal.reason : new Error("Turn aborted before session start."); + } + const onExternalAbort = () => session.abort(`external:${runId}`); + input.signal?.addEventListener("abort", onExternalAbort, { once: true }); if (input.timeoutMs !== undefined && Number.isFinite(input.timeoutMs) && input.timeoutMs > 0) { timeoutHandle = setTimeout(() => { timedOut = true; @@ -443,8 +488,9 @@ export class InProcessGateway implements Gateway { content: [{ type: "text" as const, text: s.text }], metadata: { synthetic: true, purpose: s.purpose ?? "channel_hint" }, })); - for await (const event of session.submit( - agentInput, + try { + for await (const event of session.submit( + { ...agentInput, isMeta: input.origin?.kind === "internal" }, { turnId: runId, maxTurns: input.maxTurns, @@ -490,6 +536,9 @@ export class InProcessGateway implements Gateway { this.recordActiveTurnEvent(input.sessionKey, gatewayEvent); queue.enqueue(gatewayEvent); } + } + } finally { + input.signal?.removeEventListener("abort", onExternalAbort); } } catch (error) { this.options.telemetry?.trackError(error, { diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 757586d8..7b005145 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -13,7 +13,19 @@ export { type GatewayMemoryDiagnosticInput, type GatewayMemoryDiagnosticSession, } from "./memoryDiagnostics.js"; -export { InProcessGateway, mapAgentEvent, type InProcessGatewayOptions } from "./client/InProcessGateway.js"; +export { + InProcessGateway, + mapAgentEvent, + type InProcessGatewayOptions, + type InProcessGatewaySubmitTurnInput, +} from "./client/InProcessGateway.js"; +export { + PromptDispatchGate, + type InternalPromptRequest, + type InternalPromptResult, + type InternalTurnResult, + type PromptDispatchHost, +} from "./internal/PromptDispatchGate.js"; export { GatewayWsClient, GatewayRequestError, type GatewayWsClientOptions } from "./client/GatewayWsClient.js"; export { RemoteGateway, createRemoteGateway } from "./client/RemoteGateway.js"; export { connectRemoteGatewayIfAvailable, probeGatewayServer, type ProbeGatewayServerOptions } from "./client/probeServer.js"; diff --git a/src/gateway/internal/PromptDispatchGate.ts b/src/gateway/internal/PromptDispatchGate.ts new file mode 100644 index 00000000..486b1be8 --- /dev/null +++ b/src/gateway/internal/PromptDispatchGate.ts @@ -0,0 +1,209 @@ +import { randomUUID } from "node:crypto"; +import type { GatewayChannelKey } from "../protocol/types.js"; + +export type InternalPromptRequest = { + sessionKey: string; + prompt: string; + source: string; + dedupeKey: string; + target?: { channelKey: GatewayChannelKey; projectKey?: string }; + queueBehavior?: "enqueue" | "defer"; + settleMs?: number; + waitTimeoutMs?: number; + turnTimeoutMs?: number; + recentDedupeMs?: number; + signal?: AbortSignal; +}; + +export type InternalPromptResult = + | { status: "completed"; dispatchId: string; runId: string; finishReason: string; coalesced?: boolean } + | { status: "deduped"; dispatchId: string } + | { status: "deferred"; reason: "busy" } + | { status: "aborted"; phase: "queued" | "waiting_idle" | "running" } + | { status: "timed_out"; phase: "waiting_idle" | "running" } + | { status: "failed"; phase: "dispatch" | "running"; error: unknown }; + +export type InternalTurnResult = + | { status: "completed"; runId: string; finishReason: string } + | { status: "busy" } + | { status: "timed_out" } + | { status: "failed"; error: unknown }; + +export type PromptDispatchHost = { + isSessionBusy(sessionKey: string): boolean; + waitForSessionIdle(sessionKey: string, options: { signal?: AbortSignal; timeoutMs?: number }): Promise; + runInternalTurn(input: { + sessionKey: string; + prompt: string; + source: string; + runId: string; + channelKey: GatewayChannelKey; + projectKey?: string; + timeoutMs?: number; + signal?: AbortSignal; + }): Promise; +}; + +export class PromptDispatchGate { + private readonly pending = new Map>(); + private readonly tails = new Map>(); + private readonly recent = new Map(); + private disposed = false; + + constructor( + private readonly host: PromptDispatchHost, + private readonly options: { now?: () => number; uuid?: () => string } = {}, + ) {} + + dispatch(request: InternalPromptRequest): Promise { + if (this.disposed) return Promise.resolve({ status: "failed", phase: "dispatch", error: new Error("Prompt dispatch gate is disposed.") }); + if (request.signal?.aborted) return Promise.resolve({ status: "aborted", phase: "queued" }); + if (request.queueBehavior === "defer" && this.host.isSessionBusy(request.sessionKey)) { + return Promise.resolve({ status: "deferred", reason: "busy" }); + } + + const key = `${request.sessionKey}\0${request.dedupeKey}`; + const now = this.now(); + this.pruneRecent(now); + const recent = this.recent.get(key); + const recentWindow = Math.max(0, request.recentDedupeMs ?? 30_000); + if (recent && now <= recent.expiresAt) { + return Promise.resolve({ status: "deduped", dispatchId: recent.dispatchId }); + } + const existing = this.pending.get(key); + if (existing) { + return existing.then((result) => result.status === "completed" ? { ...result, coalesced: true } : result); + } + + const previous = this.tails.get(request.sessionKey) ?? Promise.resolve(); + const operation = previous + .catch(() => undefined) + .then(() => this.execute(request, key)); + this.pending.set(key, operation); + const tail = operation.then(() => undefined, () => undefined); + this.tails.set(request.sessionKey, tail); + const cleanup = () => { + if (this.pending.get(key) === operation) this.pending.delete(key); + if (this.tails.get(request.sessionKey) === tail) this.tails.delete(request.sessionKey); + }; + void operation.then(cleanup, cleanup); + return operation; + } + + dispose(): void { + this.disposed = true; + this.recent.clear(); + } + + private async execute(request: InternalPromptRequest, key: string): Promise { + const dispatchId = this.uuid(); + const startedAt = this.now(); + const recentWindow = Math.max(0, request.recentDedupeMs ?? 30_000); + const remainingWait = () => request.waitTimeoutMs === undefined + ? undefined + : Math.max(0, request.waitTimeoutMs - (this.now() - startedAt)); + + while (true) { + if (this.disposed) return this.disposedResult(); + if (request.signal?.aborted) return { status: "aborted", phase: "waiting_idle" }; + if (this.host.isSessionBusy(request.sessionKey)) { + if (request.queueBehavior === "defer") return { status: "deferred", reason: "busy" }; + const timeoutMs = remainingWait(); + if (timeoutMs === 0) return { status: "timed_out", phase: "waiting_idle" }; + try { + await this.host.waitForSessionIdle(request.sessionKey, { signal: request.signal, timeoutMs }); + } catch (error) { + if (request.signal?.aborted || isAbortError(error)) return { status: "aborted", phase: "waiting_idle" }; + return { status: "timed_out", phase: "waiting_idle" }; + } + } + + if (this.disposed) return this.disposedResult(); + + if ((request.settleMs ?? 0) > 0) { + try { + await delay(request.settleMs!, request.signal); + } catch { + return { status: "aborted", phase: "waiting_idle" }; + } + } + + if (this.disposed) return this.disposedResult(); + + const runId = this.uuid(); + let result: InternalTurnResult; + try { + result = await this.host.runInternalTurn({ + sessionKey: request.sessionKey, + prompt: request.prompt, + source: request.source, + runId, + channelKey: request.target?.channelKey ?? "internal", + projectKey: request.target?.projectKey, + timeoutMs: request.turnTimeoutMs, + signal: request.signal, + }); + } catch (error) { + return request.signal?.aborted + ? { status: "aborted", phase: "running" } + : { status: "failed", phase: "dispatch", error }; + } + if (result.status === "busy") continue; + if (result.status === "timed_out") return { status: "timed_out", phase: "running" }; + if (result.status === "failed") { + return request.signal?.aborted + ? { status: "aborted", phase: "running" } + : { status: "failed", phase: "running", error: result.error }; + } + + if (recentWindow > 0) { + this.recent.set(key, { dispatchId, expiresAt: this.now() + recentWindow }); + this.pruneRecent(this.now()); + } + return { status: "completed", dispatchId, runId: result.runId, finishReason: result.finishReason }; + } + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private uuid(): string { + return this.options.uuid?.() ?? randomUUID(); + } + + private pruneRecent(now: number): void { + for (const [key, entry] of this.recent) { + if (entry.expiresAt < now) this.recent.delete(key); + } + while (this.recent.size > 4_096) { + const oldest = this.recent.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.recent.delete(oldest); + } + } + + private disposedResult(): InternalPromptResult { + return { status: "failed", phase: "dispatch", error: new Error("Prompt dispatch gate is disposed.") }; + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal?.reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/gateway/server/GatewayWsConnection.ts b/src/gateway/server/GatewayWsConnection.ts index 72fdd8e4..66095859 100644 --- a/src/gateway/server/GatewayWsConnection.ts +++ b/src/gateway/server/GatewayWsConnection.ts @@ -88,12 +88,13 @@ export class GatewayWsConnection { private async handleRequest(frame: WsRequestFrame): Promise { try { if (frame.method === "submit_turn") { - const sessionKey = (frame.params as { sessionKey?: string } | undefined)?.sessionKey; + const publicParams = sanitizePublicSubmitTurnParams(frame.params); + const sessionKey = publicParams.sessionKey; if (sessionKey) this.inFlightSessions.add(sessionKey); let seq = 0; let lastCompleted: GatewayEvent | undefined; try { - for await (const event of this.options.gateway.submitTurn(frame.params as never)) { + for await (const event of this.options.gateway.submitTurn(publicParams as never)) { if (event.type === "turn_completed") { lastCompleted = event; } @@ -316,3 +317,11 @@ function isRequestFrame(value: unknown): value is WsRequestFrame { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } + +export function sanitizePublicSubmitTurnParams(params: unknown): Record & { sessionKey?: string } { + if (!isRecord(params) || Array.isArray(params)) return {}; + const publicParams = { ...params }; + delete publicParams.origin; + delete publicParams.signal; + return publicParams; +} diff --git a/tests/gateway/internal-prompt-dispatch-integration.spec.ts b/tests/gateway/internal-prompt-dispatch-integration.spec.ts new file mode 100644 index 00000000..f259bb0f --- /dev/null +++ b/tests/gateway/internal-prompt-dispatch-integration.spec.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { AgentInput, AgentSession, AgentSubmitOptions } from "../../src/agent/index.js"; +import { InProcessGateway } from "../../src/gateway/client/InProcessGateway.js"; +import { SessionRouter } from "../../src/gateway/SessionRouter.js"; + +test("internal prompt dispatch waits for the user turn and fully drains the synthetic turn", async () => { + const submissions: Array<{ input: AgentInput; options: AgentSubmitOptions }> = []; + let generatorCleanedUp = false; + const router = new SessionRouter({ + idleSweepIntervalMs: 0, + createSession: () => ({ + async *submit(input: AgentInput, options: AgentSubmitOptions = {}) { + submissions.push({ input, options }); + const turnId = options.turnId ?? "internal-turn"; + try { + yield { type: "turn_started", sessionId: "session-1", turnId }; + yield { + type: "turn_completed", + sessionId: "session-1", + turnId, + result: { + type: "success", + sessionId: "session-1", + turnId, + stopReason: "completed", + usage: {}, + permissionDenials: [], + turns: 1, + startedAt: "2026-07-22T00:00:00.000Z", + completedAt: "2026-07-22T00:00:00.000Z", + }, + }; + } finally { + generatorCleanedUp = true; + } + }, + abort() {}, + snapshot() { + return { sessionId: "session-1", messages: [], usage: {}, status: "idle", permissionDenials: [] }; + }, + }) as unknown as AgentSession, + }); + const gateway = new InProcessGateway(router, { + uuid: (() => { + let sequence = 0; + return () => `generated-${++sequence}`; + })(), + }); + + assert.equal(router.beginTurn("session-1", "user-turn"), true); + const pending = gateway.getInternalPromptDispatcher().dispatch({ + sessionKey: "session-1", + prompt: "Continue the bounded maintenance task.", + source: "goal-monitor", + dedupeKey: "goal-1:checkpoint-2", + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(submissions.length, 0); + + router.endTurn("session-1", "user-turn"); + const result = await pending; + + assert.equal(result.status, "completed"); + assert.equal(submissions.length, 1); + assert.deepEqual(submissions[0]?.input, { + type: "text", + text: "Continue the bounded maintenance task.", + isMeta: true, + }); + assert.equal(submissions[0]?.options.canPrompt, false); + assert.equal(generatorCleanedUp, true); + assert.equal(router.isTurnInFlight("session-1"), false); + router.shutdown(); +}); diff --git a/tests/gateway/prompt-dispatch-gate.spec.ts b/tests/gateway/prompt-dispatch-gate.spec.ts new file mode 100644 index 00000000..e0283973 --- /dev/null +++ b/tests/gateway/prompt-dispatch-gate.spec.ts @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + PromptDispatchGate, + type InternalTurnResult, + type PromptDispatchHost, +} from "../../src/gateway/internal/PromptDispatchGate.js"; + +class FakeHost implements PromptDispatchHost { + busy = false; + runs: string[] = []; + private idleResolvers: Array<() => void> = []; + nextResults: InternalTurnResult[] = []; + + isSessionBusy(): boolean { + return this.busy; + } + + async waitForSessionIdle(_sessionKey: string, options: { signal?: AbortSignal; timeoutMs?: number }): Promise { + if (!this.busy) return; + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | undefined; + const onAbort = () => { + cleanup(); + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }; + const cleanup = () => { + options.signal?.removeEventListener("abort", onAbort); + if (timer) clearTimeout(timer); + }; + this.idleResolvers.push(() => { + cleanup(); + resolve(); + }); + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.timeoutMs !== undefined) timer = setTimeout(() => reject(new Error("timeout")), options.timeoutMs); + }); + } + + async runInternalTurn(input: { prompt: string; runId: string }): Promise { + this.runs.push(input.prompt); + return this.nextResults.shift() ?? { status: "completed", runId: input.runId, finishReason: "completed" }; + } + + releaseIdle(): void { + this.busy = false; + for (const resolve of this.idleResolvers.splice(0)) resolve(); + } +} + +function request(overrides: Record = {}) { + return { + sessionKey: "session-1", + prompt: "continue work", + source: "goal", + dedupeKey: "goal:continue:v1", + ...overrides, + }; +} + +test("defer returns immediately while a user turn is busy", async () => { + const host = new FakeHost(); + host.busy = true; + const gate = new PromptDispatchGate(host); + assert.deepEqual(await gate.dispatch(request({ queueBehavior: "defer" })), { status: "deferred", reason: "busy" }); + assert.deepEqual(host.runs, []); +}); + +test("enqueue waits for idle and dispatches exactly once", async () => { + const host = new FakeHost(); + host.busy = true; + let sequence = 0; + const gate = new PromptDispatchGate(host, { uuid: () => `id-${++sequence}` }); + const pending = gate.dispatch(request({ queueBehavior: "enqueue" })); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(host.runs, []); + host.releaseIdle(); + const result = await pending; + assert.equal(result.status, "completed"); + assert.deepEqual(host.runs, ["continue work"]); +}); + +test("same semantic key coalesces in-flight and dedupes after completion", async () => { + const host = new FakeHost(); + host.busy = true; + let now = 100; + let sequence = 0; + const gate = new PromptDispatchGate(host, { now: () => now, uuid: () => `id-${++sequence}` }); + const first = gate.dispatch(request({ queueBehavior: "enqueue", recentDedupeMs: 500 })); + const second = gate.dispatch(request({ queueBehavior: "enqueue", recentDedupeMs: 500 })); + host.releaseIdle(); + assert.equal((await first).status, "completed"); + const secondResult = await second; + assert.equal(secondResult.status, "completed"); + if (secondResult.status === "completed") assert.equal(secondResult.coalesced, true); + assert.equal(host.runs.length, 1); + + const recent = await gate.dispatch(request({ recentDedupeMs: 500 })); + assert.equal(recent.status, "deduped"); + now = 700; + assert.equal((await gate.dispatch(request({ recentDedupeMs: 500 }))).status, "completed"); + assert.equal(host.runs.length, 2); +}); + +test("an idle-settle race retries when the gateway reports busy", async () => { + const host = new FakeHost(); + host.nextResults.push({ status: "busy" }); + const gate = new PromptDispatchGate(host); + const result = await gate.dispatch(request()); + assert.equal(result.status, "completed"); + assert.equal(host.runs.length, 2); +}); + +test("abort while queued does not run an internal turn", async () => { + const host = new FakeHost(); + host.busy = true; + const controller = new AbortController(); + const gate = new PromptDispatchGate(host); + const pending = gate.dispatch(request({ queueBehavior: "enqueue", signal: controller.signal })); + controller.abort("cancelled"); + const result = await pending; + assert.deepEqual(result, { status: "aborted", phase: "waiting_idle" }); + assert.deepEqual(host.runs, []); +}); + +test("turn timeouts are reported distinctly from generic execution failures", async () => { + const host = new FakeHost(); + host.nextResults.push({ status: "timed_out" }); + const gate = new PromptDispatchGate(host); + + assert.deepEqual(await gate.dispatch(request()), { status: "timed_out", phase: "running" }); +}); + +test("a zero recent-dedupe window disables completed-result deduplication", async () => { + const host = new FakeHost(); + const gate = new PromptDispatchGate(host); + + assert.equal((await gate.dispatch(request({ recentDedupeMs: 0 }))).status, "completed"); + assert.equal((await gate.dispatch(request({ recentDedupeMs: 0 }))).status, "completed"); + assert.equal(host.runs.length, 2); +}); + +test("dispose prevents a queued prompt from starting after shutdown", async () => { + const host = new FakeHost(); + host.busy = true; + const gate = new PromptDispatchGate(host); + const pending = gate.dispatch(request()); + + gate.dispose(); + host.releaseIdle(); + const result = await pending; + + assert.equal(result.status, "failed"); + assert.deepEqual(host.runs, []); +}); diff --git a/tests/gateway/public-submit-turn-boundary.spec.ts b/tests/gateway/public-submit-turn-boundary.spec.ts new file mode 100644 index 00000000..7e4a99da --- /dev/null +++ b/tests/gateway/public-submit-turn-boundary.spec.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizePublicSubmitTurnParams } from "../../src/gateway/server/GatewayWsConnection.js"; + +test("the public WebSocket boundary strips trusted in-process turn controls", () => { + const sanitized = sanitizePublicSubmitTurnParams({ + sessionKey: "session-1", + channelKey: "web", + message: "hello", + origin: { kind: "internal", source: "spoofed" }, + signal: { aborted: false }, + }); + + assert.deepEqual(sanitized, { + sessionKey: "session-1", + channelKey: "web", + message: "hello", + }); +}); diff --git a/tests/gateway/session-router-idle.spec.ts b/tests/gateway/session-router-idle.spec.ts new file mode 100644 index 00000000..62d3d11e --- /dev/null +++ b/tests/gateway/session-router-idle.spec.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SessionRouter } from "../../src/gateway/SessionRouter.js"; + +function createRouter(): SessionRouter { + return new SessionRouter({ + idleSweepIntervalMs: 0, + createSession: async () => ({ abort() {}, snapshot: () => ({ messages: [] }) }) as never, + }); +} + +test("waitForIdle resolves only after the matching turn slot is released", async () => { + const router = createRouter(); + assert.equal(router.beginTurn("session-1", "run-1"), true); + let resolved = false; + const pending = router.waitForIdle("session-1").then(() => { resolved = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(resolved, false); + router.endTurn("session-1", "other-run"); + assert.equal(resolved, false); + router.endTurn("session-1", "run-1"); + await pending; + assert.equal(resolved, true); + router.shutdown(); +}); + +test("waitForIdle honors AbortSignal without releasing the turn", async () => { + const router = createRouter(); + router.beginTurn("session-1", "run-1"); + const controller = new AbortController(); + const pending = router.waitForIdle("session-1", { signal: controller.signal }); + controller.abort("cancelled"); + await assert.rejects(pending, { name: "AbortError" }); + assert.equal(router.isTurnInFlight("session-1"), true); + router.endTurn("session-1", "run-1"); + router.shutdown(); +}); From 15f27a6f08c5b0fca121ee88b26109467b368bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:33 +0800 Subject: [PATCH 05/12] feat(agent): integrate runtime context and artifact controls --- src/agent/loop/AgentLoop.ts | 85 ++++++- src/agent/runtime/AgentRuntimeDependencies.ts | 2 + src/agent/session/AgentSession.ts | 145 ++++++----- src/agent/turn/TurnInputProcessor.ts | 2 + src/agent/turn/TurnRunner.ts | 2 +- src/cli/createLocalGateway.ts | 28 ++- .../agent/agent-loop-runtime-controls.spec.ts | 228 ++++++++++++++++++ tests/agent/agent-session-cleanup.spec.ts | 61 +++++ .../local-gateway-runtime-controls.spec.ts | 148 ++++++++++++ 9 files changed, 634 insertions(+), 67 deletions(-) create mode 100644 tests/agent/agent-loop-runtime-controls.spec.ts create mode 100644 tests/agent/agent-session-cleanup.spec.ts create mode 100644 tests/agent/local-gateway-runtime-controls.spec.ts diff --git a/src/agent/loop/AgentLoop.ts b/src/agent/loop/AgentLoop.ts index 4f1960a6..32c700f1 100644 --- a/src/agent/loop/AgentLoop.ts +++ b/src/agent/loop/AgentLoop.ts @@ -41,6 +41,8 @@ import type { AgentRuntimeConfig } from "../runtime/AgentRuntimeConfig.js"; import type { AgentRuntimeDependencies } from "../runtime/AgentRuntimeDependencies.js"; import type { LifecycleDispatchResult } from "../../lifecycle/index.js"; import type { PilotDeckHookEvent } from "../../extension/hooks/protocol/events.js"; +import { applyPreModelRequestEffects } from "../../lifecycle/index.js"; +import { formatArtifactCorrectionPrompt } from "../../artifact/index.js"; import { NullContextRuntime } from "../../context/NullContextRuntime.js"; import type { AgentContextRuntime } from "../../context/ContextRuntime.js"; import type { ContextRecoveryDecision, ContextSupplementalToolResultMessage, TokenBudgetSnapshot } from "../../context/index.js"; @@ -167,6 +169,8 @@ export class AgentLoop { const startedAt = this.now().toISOString(); let messages = [...input.messages]; let turnCount = 1; + let artifactCorrectionAttempts = 0; + const maxArtifactCorrectionAttempts = 2; let usage: CanonicalUsage = {}; let lastModelUsage: CanonicalUsage | undefined; let permissionDenials: AgentPermissionDenial[] = []; @@ -400,10 +404,32 @@ export class AgentLoop { yield { type: "turn_completed", sessionId: input.sessionId, turnId: input.turnId, result }; return { result, messages }; } - this.dispatchLifecycle(input, "PreModelRequest", { + const preModelHooks = await this.dispatchLifecycle(input, "PreModelRequest", { provider: request.provider, model: request.model, - }).catch(() => {}); + turnId: input.turnId, + contextBudget: pendingContextBudget, + }); + const transformedRequest = applyPreModelRequestEffects(request, preModelHooks); + if (transformedRequest.type === "blocked") { + const error = agentError("agent_unsupported_feature", transformedRequest.reason); + const result = this.createTurnResult(input, { + type: "error", + stopReason: "tool_error", + usage, + permissionDenials, + turns: turnCount, + startedAt, + finalMessage, + errors: [error], + }); + yield await emitStatus(createLifecycleBlockedStatus({ error, stage: "pre_model_request" })); + yield { type: "turn_failed", sessionId: input.sessionId, turnId: input.turnId, error }; + await captureTurn(true); + yield { type: "turn_completed", sessionId: input.sessionId, turnId: input.turnId, result }; + return { result, messages }; + } + request = transformedRequest.request; yield { type: "model_request_started", sessionId: input.sessionId, @@ -453,6 +479,11 @@ export class AgentLoop { if (recompact.type === "compacted") { messages = recompact.messages; request = await this.createModelRequest(messages, input); + const retransformedRequest = applyPreModelRequestEffects(request, preModelHooks); + if (retransformedRequest.type === "blocked") { + throw new Error("PreModelRequest effects changed from ready to blocked while rebuilding the request."); + } + request = retransformedRequest.request; request = this.applyTokenCapsToRequest(request, decision.provider, decision.model); yield { type: "turn_continued", @@ -486,6 +517,10 @@ export class AgentLoop { const assembler = createModelMessageAssemblerState(); try { + this.dependencies.context?.commitPreparedContext?.({ + sessionId: input.sessionId, + turnId: input.turnId, + }); for await (const event of this.dependencies.router.execute(decision, request, { sessionId: input.sessionId, turnId: input.turnId, @@ -1308,6 +1343,39 @@ export class AgentLoop { }; } + const artifactValidation = this.dependencies.artifactValidation + ? await this.dependencies.artifactValidation.validate({ + sessionId: input.sessionId, + turnId: input.turnId, + workspaceRoot: this.config.cwd, + signal: input.abortSignal, + }) + : undefined; + if (artifactValidation && !artifactValidation.passed) { + if (artifactCorrectionAttempts < maxArtifactCorrectionAttempts && (!input.maxTurns || turnCount < input.maxTurns)) { + artifactCorrectionAttempts += 1; + turnCount += 1; + pushTransientSyntheticPrompt(formatArtifactCorrectionPrompt(artifactValidation), "artifact_validation_failed"); + yield { type: "turn_continued", sessionId: input.sessionId, turnId: input.turnId, reason: "model_error" }; + continue; + } + const error = agentError("agent_unsupported_feature", formatArtifactCorrectionPrompt(artifactValidation)); + const result = this.createTurnResult(input, { + type: "error", + stopReason: "tool_error", + usage, + permissionDenials, + turns: turnCount, + startedAt, + finalMessage, + errors: [error], + }); + yield { type: "turn_failed", sessionId: input.sessionId, turnId: input.turnId, error }; + await captureTurn(true); + yield { type: "turn_completed", sessionId: input.sessionId, turnId: input.turnId, result }; + return { result, messages }; + } + const stopHooks = await this.dispatchLifecycle(input, "Stop", { stopHookActive: false, lastAssistantMessage: textFromMessage(assistantMessage), @@ -1875,11 +1943,22 @@ export class AgentLoop { } private applyTokenCapsToRequest(request: CanonicalModelRequest, provider: string, model: string): CanonicalModelRequest { + const transient = this.transientTokenCaps.get(this.tokenCapKey(provider, model)); + const modelMaxOutputTokens = this.getModelTokenLimits(provider, model)?.maxOutputTokens; + const requested = transient?.attemptMaxOutputTokens + ?? request.maxOutputTokens + ?? transient?.requestedMaxOutputTokens + ?? this.config.maxOutputTokens + ?? modelMaxOutputTokens; + const candidates = [requested, modelMaxOutputTokens, transient?.hardMaxOutputTokens] + .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0); return { ...request, provider, model, - maxOutputTokens: this.currentMaxOutputTokens(provider, model), + maxOutputTokens: candidates.length > 0 + ? Math.min(...candidates.map((value) => Math.floor(value))) + : undefined, }; } diff --git a/src/agent/runtime/AgentRuntimeDependencies.ts b/src/agent/runtime/AgentRuntimeDependencies.ts index 8b9e5b0f..58543533 100644 --- a/src/agent/runtime/AgentRuntimeDependencies.ts +++ b/src/agent/runtime/AgentRuntimeDependencies.ts @@ -14,6 +14,7 @@ import type { AgentContextRuntime } from "../../context/ContextRuntime.js"; import type { TokenAccountingRuntime } from "../../context/index.js"; import type { RouterRuntime } from "../../router/index.js"; import type { AgentEvent, AgentEventEmitter } from "../protocol/events.js"; +import type { ArtifactValidationRuntime } from "../../artifact/index.js"; /** * Narrow view of the router that the agent loop actually consumes. Tests can @@ -137,6 +138,7 @@ export type AgentRuntimeDependencies = { /** Session-scoped state tracking required `todo_write` calls after plan approval. */ planTodoManager?: PlanTodoStateManager; eventEmitter?: AgentEventEmitter; + artifactValidation?: ArtifactValidationRuntime; drainEvents?: () => AgentEvent[]; }; diff --git a/src/agent/session/AgentSession.ts b/src/agent/session/AgentSession.ts index cee7f170..5a011321 100644 --- a/src/agent/session/AgentSession.ts +++ b/src/agent/session/AgentSession.ts @@ -46,69 +46,92 @@ export class AgentSession { this.state.status = "running"; this.state.currentTurnId = turnId; this.state.abortController = new AbortController(); - yield { type: "session_started", sessionId: this.state.sessionId }; - await this.options.lifecycle?.dispatch({ - event: "SessionStart", - baseInput: { - sessionId: this.state.sessionId, - transcriptPath: this.options.transcriptPath ?? "", - cwd: this.options.cwd ?? process.cwd(), - }, - payload: { source: "startup" }, - matchQuery: "SessionStart", - signal: this.state.abortController.signal, - }); - await this.options.lifecycle?.dispatch({ - event: "Setup", - baseInput: { - sessionId: this.state.sessionId, - transcriptPath: this.options.transcriptPath ?? "", - cwd: this.options.cwd ?? process.cwd(), - }, - payload: {}, - matchQuery: "Setup", - signal: this.state.abortController.signal, - }); - yield { type: "setup_completed", sessionId: this.state.sessionId }; - - const runResult = yield* this.options.turnRunner.run({ - sessionId: this.state.sessionId, - turnId, - messages: this.state.messages, - input, - maxTurns: submitOptions.maxTurns, - runMode: submitOptions.runMode, - permissionMode: submitOptions.permissionMode, - allowedReadFiles: submitOptions.allowedReadFiles, - basePermissionMode: submitOptions.basePermissionMode, - allowPlanModeTools: submitOptions.allowPlanModeTools, - canPrompt: submitOptions.canPrompt, - permissionRules: submitOptions.permissionRules, - syntheticMessages: submitOptions.syntheticMessages, - abortSignal: this.state.abortController.signal, - }); + let sessionEndAttempted = false; + try { + yield { type: "session_started", sessionId: this.state.sessionId }; + await this.options.lifecycle?.dispatch({ + event: "SessionStart", + baseInput: { + sessionId: this.state.sessionId, + transcriptPath: this.options.transcriptPath ?? "", + cwd: this.options.cwd ?? process.cwd(), + }, + payload: { source: "startup" }, + matchQuery: "SessionStart", + signal: this.state.abortController.signal, + }); + await this.options.lifecycle?.dispatch({ + event: "Setup", + baseInput: { + sessionId: this.state.sessionId, + transcriptPath: this.options.transcriptPath ?? "", + cwd: this.options.cwd ?? process.cwd(), + }, + payload: {}, + matchQuery: "Setup", + signal: this.state.abortController.signal, + }); + yield { type: "setup_completed", sessionId: this.state.sessionId }; - this.state.messages = runResult.messages; - this.state.usage = mergeSessionUsage(this.state.usage, runResult.result.usage); - this.state.permissionDenials = appendPermissionDenials( - this.state.permissionDenials, - runResult.result.permissionDenials, - ); - this.state.status = runResult.result.type === "aborted" ? "aborted" : runResult.result.type === "error" ? "failed" : "idle"; - this.state.currentTurnId = undefined; - const sessionEndReason = this.state.status === "aborted" ? "other" : "prompt_input_exit"; - await this.options.lifecycle?.dispatch({ - event: "SessionEnd", - baseInput: { + const runResult = yield* this.options.turnRunner.run({ sessionId: this.state.sessionId, - transcriptPath: this.options.transcriptPath ?? "", - cwd: this.options.cwd ?? process.cwd(), - }, - payload: { reason: sessionEndReason }, - matchQuery: "SessionEnd", - signal: this.state.abortController.signal, - }); - yield { type: "session_ended", sessionId: this.state.sessionId, reason: sessionEndReason }; + turnId, + messages: this.state.messages, + input, + maxTurns: submitOptions.maxTurns, + runMode: submitOptions.runMode, + permissionMode: submitOptions.permissionMode, + allowedReadFiles: submitOptions.allowedReadFiles, + basePermissionMode: submitOptions.basePermissionMode, + allowPlanModeTools: submitOptions.allowPlanModeTools, + canPrompt: submitOptions.canPrompt, + permissionRules: submitOptions.permissionRules, + syntheticMessages: submitOptions.syntheticMessages, + abortSignal: this.state.abortController.signal, + }); + + this.state.messages = runResult.messages; + this.state.usage = mergeSessionUsage(this.state.usage, runResult.result.usage); + this.state.permissionDenials = appendPermissionDenials( + this.state.permissionDenials, + runResult.result.permissionDenials, + ); + this.state.status = runResult.result.type === "aborted" ? "aborted" : runResult.result.type === "error" ? "failed" : "idle"; + this.state.currentTurnId = undefined; + const sessionEndReason = this.state.status === "aborted" ? "other" : "prompt_input_exit"; + sessionEndAttempted = true; + await this.options.lifecycle?.dispatch({ + event: "SessionEnd", + baseInput: { + sessionId: this.state.sessionId, + transcriptPath: this.options.transcriptPath ?? "", + cwd: this.options.cwd ?? process.cwd(), + }, + payload: { reason: sessionEndReason }, + matchQuery: "SessionEnd", + signal: this.state.abortController.signal, + }); + yield { type: "session_ended", sessionId: this.state.sessionId, reason: sessionEndReason }; + } finally { + if (this.state.currentTurnId === turnId) { + this.state.status = this.state.abortController.signal.aborted ? "aborted" : "failed"; + this.state.currentTurnId = undefined; + } + if (!sessionEndAttempted) { + const reason = this.state.status === "aborted" ? "other" : "prompt_input_exit"; + await this.options.lifecycle?.dispatch({ + event: "SessionEnd", + baseInput: { + sessionId: this.state.sessionId, + transcriptPath: this.options.transcriptPath ?? "", + cwd: this.options.cwd ?? process.cwd(), + }, + payload: { reason }, + matchQuery: "SessionEnd", + signal: this.state.abortController.signal, + }).catch(() => undefined); + } + } } abort(reason?: string): void { diff --git a/src/agent/turn/TurnInputProcessor.ts b/src/agent/turn/TurnInputProcessor.ts index 916f103c..ab130acb 100644 --- a/src/agent/turn/TurnInputProcessor.ts +++ b/src/agent/turn/TurnInputProcessor.ts @@ -15,6 +15,7 @@ export class TurnInputProcessor { { role: "user", content: [{ type: "text", text: input.text }], + metadata: input.isMeta ? { synthetic: true, purpose: "internal_prompt" } : undefined, }, ], }; @@ -26,6 +27,7 @@ export class TurnInputProcessor { { role: "user", content: input.content, + metadata: input.isMeta ? { synthetic: true, purpose: "internal_prompt" } : undefined, }, ], }; diff --git a/src/agent/turn/TurnRunner.ts b/src/agent/turn/TurnRunner.ts index 85f3d16f..0b57d9c8 100644 --- a/src/agent/turn/TurnRunner.ts +++ b/src/agent/turn/TurnRunner.ts @@ -113,7 +113,7 @@ export class TurnRunner { transcriptPath: this.runtimeContext.transcriptPath, cwd: this.runtimeContext.cwd, }, - payload: { prompt }, + payload: { prompt, internal: options.input.isMeta === true, turnId: options.turnId }, matchQuery: "UserPromptSubmit", signal: options.abortSignal, }); diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index a292a091..6de504e2 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -20,6 +20,7 @@ import { ContextOverflowRecovery, DefaultContextRuntime, DEFAULT_PROTECTED_TOOL_RESULT_NAMES, + DynamicContextStore, InstructionDiscovery, MicroCompactionEngine, PluginRuntimeExtensionResolver, @@ -32,7 +33,12 @@ import { import { FileHistoryStore } from "../session/filesystem/FileHistoryStore.js"; import type { AgentSubagentTranscriptHooks } from "../agent/runtime/AgentRuntimeDependencies.js"; import { createPlanTodoStateManager } from "../agent/runtime/PlanTodoState.js"; -import { HookRuntime, PluginRuntime } from "../extension/index.js"; +import { + DomainPluginRuntime, + HookRuntime, + PluginRuntime, + type DomainPlugin, +} from "../extension/index.js"; import { LifecycleRuntime } from "../lifecycle/index.js"; import { GatewayElicitationChannel, @@ -87,6 +93,7 @@ import { loadBuiltinPlugins } from "../extension/plugins/builtin/loadBuiltinPlug import { SkillManager, migrateLegacyBundledSkillCopies } from "../extension/skills/index.js"; import { ExtensionWatchManager, type ExtensionWatchEvent } from "./ExtensionWatchManager.js"; import { createTelemetryCollector, type TelemetryClient } from "../telemetry/index.js"; +import { ArtifactContractStore, ArtifactValidationRuntime, FileExistsValidator } from "../artifact/index.js"; export type CreateLocalGatewayOptions = { projectRoot?: string; @@ -128,6 +135,8 @@ export type CreateLocalGatewayOptions = { */ autoElicitation?: boolean; telemetry?: TelemetryClient; + /** Native domain plugins. Project file plugins should use skills + lifecycle hook outputs. */ + domainPlugins?: readonly DomainPlugin[]; }; export type SubsystemUpdate = { @@ -210,6 +219,7 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre modelFactory: options.__testModelFactory, autoElicitation: options.autoElicitation, telemetry, + domainPlugins: options.domainPlugins, onProjectActivated: (activeProjectRoot) => extensionWatchManager.watchProject(activeProjectRoot), }); const defaultRuntime = registry.resolve(); @@ -392,6 +402,7 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre configStore, registry, dispose: () => { + gateway.dispose(); registry.invalidate(); router?.shutdown(); stopConfigWatching(); @@ -444,6 +455,7 @@ type ProjectRuntimeRegistryOptions = { modelFactory?: (snapshot: PilotConfigSnapshot) => ModelRuntime; autoElicitation?: boolean; telemetry: TelemetryClient; + domainPlugins?: readonly DomainPlugin[]; onProjectActivated?: (projectRoot: string) => void; }; @@ -1031,7 +1043,14 @@ class ProjectRuntimeRegistry { }), ); } - const lifecycle = new LifecycleRuntime(hookRuntime); + const dynamicContext = new DynamicContextStore(); + const artifactContracts = new ArtifactContractStore(); + const domainPlugins = new DomainPluginRuntime({ + plugins: this.options.domainPlugins ?? [], + dynamicContext, + artifactContracts, + }); + const lifecycle = new LifecycleRuntime(hookRuntime, dynamicContext, artifactContracts, domainPlugins); const extension = new PluginRuntimeExtensionResolver(runtime.pluginRuntime); const projectRoot = runtime.projectRoot; const memoryResolver = runtime.memory; @@ -1042,6 +1061,10 @@ class ProjectRuntimeRegistry { router: runtime.router, tools: { registry: sessionTools }, lifecycle, + artifactValidation: new ArtifactValidationRuntime( + artifactContracts, + [new FileExistsValidator(), ...domainPlugins.validators()], + ), now: this.options.now, eventEmitter: eventBuf.emitter, drainEvents: eventBuf.drain, @@ -1144,6 +1167,7 @@ class ProjectRuntimeRegistry { overflowRecovery, maxContextTokens: runtime.snapshot.config.agent.maxContextTokens ?? caps.maxContextTokens, now, + dynamicContext, }); const fileHistory = new FileHistoryStore({ backupDir: storage.fileHistoryDir, diff --git a/tests/agent/agent-loop-runtime-controls.spec.ts b/tests/agent/agent-loop-runtime-controls.spec.ts new file mode 100644 index 00000000..f7a3302a --- /dev/null +++ b/tests/agent/agent-loop-runtime-controls.spec.ts @@ -0,0 +1,228 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentLoop, type AgentLoopRunResult } from "../../src/agent/index.js"; +import type { AgentRuntimeConfig } from "../../src/agent/runtime/AgentRuntimeConfig.js"; +import type { AgentRuntimeDependencies } from "../../src/agent/runtime/AgentRuntimeDependencies.js"; +import { + ArtifactContractStore, + ArtifactValidationRuntime, + FileExistsValidator, +} from "../../src/artifact/index.js"; +import type { AgentContextRuntime } from "../../src/context/ContextRuntime.js"; +import { DefaultContextRuntime, DynamicContextStore } from "../../src/context/index.js"; +import type { TokenBudgetSnapshot } from "../../src/context/index.js"; +import type { CanonicalMessage, CanonicalModelEvent, CanonicalModelRequest } from "../../src/model/index.js"; +import { createDefaultPermissionContext } from "../../src/permission/index.js"; +import type { RouterDecision } from "../../src/router/index.js"; +import { ToolRegistry } from "../../src/tool/index.js"; + +test("PreModelRequest mutations survive a post-routing request rebuild and remain model-only", async () => { + const requests: CanonicalModelRequest[] = []; + let compactCalls = 0; + let preModelCalls = 0; + let observedBudget: unknown; + const dynamicContext = new DynamicContextStore(); + dynamicContext.register({ + sessionId: "session-1", + source: "goal-hook", + id: "checkpoint", + content: "goal checkpoint survives request rebuild", + priority: "high", + }); + const defaultContext = new DefaultContextRuntime({ dynamicContext }); + const context: AgentContextRuntime = { + prepareForModel: (input) => defaultContext.prepareForModel(input), + commitPreparedContext: (input) => defaultContext.commitPreparedContext(input), + async tryAutoCompact(input) { + compactCalls += 1; + if (compactCalls === 1) return { type: "skipped", snapshot: budgetSnapshot(10_000) }; + return { + type: "compacted", + messages: input.messages, + tier: "micro", + snapshot: budgetSnapshot(5_000), + }; + }, + }; + const dependencies = createDependencies(requests, { + context, + getModelTokenLimits: (_provider, model) => ({ + maxContextTokens: model === "routed-review-model" ? 5_000 : 10_000, + maxOutputTokens: 2_048, + }), + lifecycle: { + async dispatch(input: { event: string }) { + if (input.event !== "PreModelRequest") return emptyLifecycleResult(); + preModelCalls += 1; + observedBudget = (input as { payload?: Record }).payload?.contextBudget; + return { + ...emptyLifecycleResult(), + messages: [{ + role: "user" as const, + content: [{ type: "text" as const, text: "current budget checkpoint" }], + metadata: { synthetic: true }, + }], + effects: [ + { type: "system_message" as const, content: "runtime policy addendum" }, + { + type: "model_request_patch" as const, + patch: { model: "routed-review-model", maxOutputTokens: 9_999, metadata: { goalId: "goal-1" } }, + }, + ], + }; + }, + } as never, + }); + const loop = new AgentLoop(createConfig(process.cwd(), { maxContextTokens: 10_000 }), dependencies); + + const completed = await drainLoop(loop.run({ + sessionId: "session-1", + turnId: "turn-1", + messages: [userMessage("original request")], + })); + + assert.equal(completed.result.type, "success"); + assert.equal(preModelCalls, 1); + assert.equal(compactCalls, 2); + assert.equal((observedBudget as TokenBudgetSnapshot | undefined)?.maxContextTokens, 10_000); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.model, "routed-review-model"); + assert.equal(requests[0]?.maxOutputTokens, 2_048); + assert.match(requests[0]?.systemPrompt ?? "", /^base system/u); + assert.match(requests[0]?.systemPrompt ?? "", /runtime policy addendum$/u); + assert.deepEqual(requests[0]?.metadata, { goalId: "goal-1" }); + assert.match(messageText(requests[0]?.messages ?? []), /current budget checkpoint/); + assert.match(messageText(requests[0]?.messages ?? []), /goal checkpoint survives request rebuild/); + assert.doesNotMatch(messageText(completed.messages), /current budget checkpoint/); + assert.doesNotMatch(messageText(completed.messages), /goal checkpoint survives request rebuild/); + assert.equal(dynamicContext.hasPending("session-1"), false); +}); + +test("artifact failure injects one bounded correction turn and succeeds after validation", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-agent-loop-artifact-")); + try { + const contracts = new ArtifactContractStore(); + contracts.register("session-1", "domain-plugin", [{ + id: "final-workbook", + path: "deliverable.xlsx", + required: true, + expectedExtensions: [".xlsx"], + validatorIds: ["core:file-exists"], + }]); + const requests: CanonicalModelRequest[] = []; + const dependencies = createDependencies(requests, { + artifactValidation: new ArtifactValidationRuntime(contracts, [new FileExistsValidator()]), + beforeResponse: async (requestIndex) => { + if (requestIndex === 2) await writeFile(join(workspace, "deliverable.xlsx"), "verified workbook fixture"); + }, + }); + const loop = new AgentLoop(createConfig(workspace), dependencies); + + const completed = await drainLoop(loop.run({ + sessionId: "session-1", + turnId: "turn-1", + messages: [userMessage("create the required deliverable")], + })); + + assert.equal(completed.result.type, "success"); + assert.equal(requests.length, 2); + assert.doesNotMatch(messageText(requests[0]?.messages ?? []), /Artifact validation failed/); + assert.match(messageText(requests[1]?.messages ?? []), /Artifact validation failed/); + assert.match(messageText(requests[1]?.messages ?? []), /deliverable\.xlsx/); + assert.equal(completed.result.turns, 2); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +function createDependencies( + requests: CanonicalModelRequest[], + options: Partial & { beforeResponse?: (requestIndex: number) => Promise } = {}, +): AgentRuntimeDependencies { + const registry = new ToolRegistry(); + const router = { + async decide(input: { request: CanonicalModelRequest }): Promise { + return { + provider: input.request.provider, + model: input.request.model, + scenarioType: "default", + isSubagent: false, + orchestrating: false, + resolvedFrom: "scenario", + mutations: {}, + }; + }, + async *execute(_decision: RouterDecision, request: CanonicalModelRequest): AsyncIterable { + requests.push(request); + await options.beforeResponse?.(requests.length); + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: `response ${requests.length}` }; + yield { type: "message_end", finishReason: "stop" }; + }, + async *stream(): AsyncIterable { + throw new Error("stream fallback should not be used"); + }, + }; + const { beforeResponse: _beforeResponse, ...dependencyOverrides } = options; + return { + router, + tools: { + registry, + scheduler: { async executeAll() { return []; } }, + }, + now: () => new Date("2026-07-22T00:00:00.000Z"), + uuid: (() => { + let sequence = 0; + return () => `id-${++sequence}`; + })(), + ...dependencyOverrides, + } as AgentRuntimeDependencies; +} + +function createConfig(cwd: string, overrides: Partial = {}): AgentRuntimeConfig { + return { + provider: "test-provider", + model: "test-model", + cwd, + systemPrompt: "base system", + permissionMode: "default", + permissionContext: createDefaultPermissionContext({ cwd }), + ...overrides, + }; +} + +function emptyLifecycleResult() { + return { effects: [], messages: [], events: [], blockingErrors: [], nonBlockingErrors: [] }; +} + +function userMessage(text: string): CanonicalMessage { + return { role: "user", content: [{ type: "text", text }] }; +} + +function messageText(messages: readonly CanonicalMessage[]): string { + return messages.flatMap((message) => message.content) + .map((block) => block.type === "text" ? block.text : "") + .filter(Boolean) + .join("\n"); +} + +function budgetSnapshot(maxContextTokens: number): TokenBudgetSnapshot { + return { + tokens: 10, + maxContextTokens, + warningRatio: 0.8, + blockingRatio: 0.95, + state: "ok", + ratio: 0.001, + }; +} + +async function drainLoop(iterator: AsyncGenerator): Promise { + while (true) { + const next = await iterator.next(); + if (next.done) return next.value; + } +} diff --git a/tests/agent/agent-session-cleanup.spec.ts b/tests/agent/agent-session-cleanup.spec.ts new file mode 100644 index 00000000..1c32a0a7 --- /dev/null +++ b/tests/agent/agent-session-cleanup.spec.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { AgentSession } from "../../src/agent/session/AgentSession.js"; + +test("AgentSession restores terminal state and dispatches SessionEnd when the runner throws", async () => { + const events: string[] = []; + const session = new AgentSession({ + sessionId: "session-1", + turnRunner: { + async *run() { + throw new Error("runner failed"); + }, + } as never, + lifecycle: { + async dispatch(input: { event: string }) { + events.push(input.event); + return { effects: [], messages: [], events: [], blockingErrors: [], nonBlockingErrors: [] }; + }, + } as never, + }); + + await assert.rejects(async () => { + for await (const _event of session.submit({ type: "text", text: "hello" })) { + // Drain the generator so its cleanup contract is exercised. + } + }, /runner failed/); + + assert.equal(session.snapshot().status, "failed"); + assert.equal(session.snapshot().currentTurnId, undefined); + assert.deepEqual(events, ["SessionStart", "Setup", "SessionEnd"]); +}); + +test("AgentSession cleanup runs when a consumer stops reading early", async () => { + const events: string[] = []; + const session = new AgentSession({ + sessionId: "session-1", + turnRunner: { + async *run() { + yield { type: "warning", sessionId: "session-1", turnId: "turn", code: "test", message: "pause" }; + return { + result: { type: "success", usage: {}, permissionDenials: [] }, + messages: [], + }; + }, + } as never, + lifecycle: { + async dispatch(input: { event: string }) { + events.push(input.event); + return { effects: [], messages: [], events: [], blockingErrors: [], nonBlockingErrors: [] }; + }, + } as never, + }); + + for await (const event of session.submit({ type: "text", text: "hello" })) { + if (event.type === "session_started") break; + } + + assert.equal(session.snapshot().status, "failed"); + assert.equal(session.snapshot().currentTurnId, undefined); + assert.deepEqual(events, ["SessionEnd"]); +}); diff --git a/tests/agent/local-gateway-runtime-controls.spec.ts b/tests/agent/local-gateway-runtime-controls.spec.ts new file mode 100644 index 00000000..f896891d --- /dev/null +++ b/tests/agent/local-gateway-runtime-controls.spec.ts @@ -0,0 +1,148 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createLocalGateway } from "../../src/cli/createLocalGateway.js"; +import { + type CanonicalMessage, + type CanonicalModelRequest, + type ModelRuntime, +} from "../../src/model/index.js"; +import { DEFAULT_MODEL_CAPABILITIES } from "../../src/model/protocol/capabilities.js"; +import { DEFAULT_MULTIMODAL_CONSTRAINTS } from "../../src/model/protocol/multimodal.js"; + +test("local gateway applies project hook context and enforces artifact correction", async () => { + const root = await mkdtemp(join(tmpdir(), "pilotdeck-runtime-controls-")); + const projectRoot = join(root, "project"); + const pilotHome = join(root, "home"); + const pluginRoot = join(projectRoot, ".pilotdeck", "plugins", "runtime-qa"); + const requests: CanonicalModelRequest[] = []; + await mkdir(join(pluginRoot, "hooks"), { recursive: true }); + await mkdir(pilotHome, { recursive: true }); + await writeFile(join(pilotHome, "pilotdeck.yaml"), TEST_CONFIG); + await writeFile(join(pluginRoot, "plugin.json"), JSON.stringify({ + name: "runtime-qa", + version: "1.0.0", + hooks: "hooks/hooks.json", + })); + await writeFile(join(pluginRoot, "hooks", "hooks.json"), JSON.stringify({ + UserPromptSubmit: [{ hooks: [{ type: "command", command: "node hook.mjs" }] }], + PreModelRequest: [{ hooks: [{ type: "command", command: "node hook.mjs" }] }], + })); + await writeFile(join(pluginRoot, "hook.mjs"), HOOK_SCRIPT); + + const runtime = createLocalGateway({ + projectRoot, + fallbackProjectRoot: projectRoot, + pilotHome, + env: { ...process.env, PILOT_HOME: pilotHome }, + __testModelFactory: () => fakeModelRuntime(requests, projectRoot), + }); + try { + const events = []; + for await (const event of runtime.gateway.submitTurn({ + sessionKey: "qa-session", + channelKey: "test", + projectKey: projectRoot, + message: "Create the required workbook.", + canPrompt: false, + })) { + events.push(event); + } + + const agentRequests = requests.filter((request) => !messageText(request.messages).includes("Summarize the conversation so far")); + assert.equal(agentRequests.length, 2); + assert.equal(agentRequests[0]?.maxOutputTokens, 1_234); + assert.equal(agentRequests[0]?.metadata?.hookQa, true); + assert.match(messageText(agentRequests[0]?.messages ?? []), /project hook checkpoint/); + assert.doesNotMatch(messageText(agentRequests[1]?.messages ?? []), /project hook checkpoint/); + assert.match(messageText(agentRequests[1]?.messages ?? []), /Artifact validation failed/); + assert.equal(events.some((event) => event.type === "turn_completed" && event.finishReason === "completed"), true); + await access(join(projectRoot, "final.xlsx")); + } finally { + runtime.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + +function fakeModelRuntime(requests: CanonicalModelRequest[], projectRoot: string): ModelRuntime { + return { + async *stream(request) { + requests.push(request); + const requestText = messageText(request.messages); + if (requestText.includes("Artifact validation failed")) { + await writeFile(join(projectRoot, "final.xlsx"), "deterministic workbook fixture"); + } + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: requestText.includes("Artifact validation failed") ? "Corrected completion." : "Initial completion." }; + yield { type: "usage", usage: { inputTokens: 10, outputTokens: 4, totalTokens: 14 } }; + yield { type: "message_end", finishReason: "stop" }; + }, + async complete() { + return { role: "assistant", content: [{ type: "text", text: '{"title":"QA session"}' }], finishReason: "stop" }; + }, + getCapabilities: () => DEFAULT_MODEL_CAPABILITIES, + getMultimodal: () => DEFAULT_MULTIMODAL_CONSTRAINTS, + getProviderProtocol: () => "openai", + getProviderBaseUrl: () => "https://example.invalid", + }; +} + +function messageText(messages: readonly CanonicalMessage[]): string { + return messages.flatMap((message) => message.content) + .map((block) => block.type === "text" ? block.text : "") + .filter(Boolean) + .join("\n"); +} + +const TEST_CONFIG = `schemaVersion: 1 +agent: + model: test/test-model + maxOutputTokens: 4096 +model: + providers: + test: + protocol: openai + url: https://example.invalid + apiKey: test-key + models: + test-model: + capabilities: + maxContextTokens: 32768 + maxOutputTokens: 8192 +router: + enabled: false + scenarios: + default: test/test-model +memory: + enabled: false +`; + +const HOOK_SCRIPT = `let body = ""; +for await (const chunk of process.stdin) body += chunk; +const input = JSON.parse(body); +let output = { hookSpecificOutput: { hookEventName: input.hookEventName } }; +if (input.hookEventName === "UserPromptSubmit" && input.internal !== true) { + output.hookSpecificOutput.dynamicContext = [{ + id: "checkpoint", + content: "project hook checkpoint", + priority: "critical", + ttlMs: 60000 + }]; + output.hookSpecificOutput.artifactContracts = [{ + id: "final-workbook", + path: "final.xlsx", + required: true, + expectedExtensions: [".xlsx"], + validatorIds: ["core:file-exists"] + }]; +} +if (input.hookEventName === "PreModelRequest") { + output.hookSpecificOutput.modelRequestPatch = { + maxOutputTokens: 1234, + metadata: { hookQa: true } + }; +} +console.log(JSON.stringify(output)); +`; From 48db3cac850561b6782f02a1f3166c371dc8e1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 02:39:40 +0800 Subject: [PATCH 06/12] test(agent): record runtime QA evidence --- .../README.md | 32 +++++++++++++++++++ .../baseline-known-failure.txt | 27 ++++++++++++++++ .../focused-tests.txt | 32 +++++++++++++++++++ .../regression-suite.txt | 15 +++++++++ 4 files changed, 106 insertions(+) create mode 100644 .omo/evidence/20260722-pilotdeck-agent-runtime/README.md create mode 100644 .omo/evidence/20260722-pilotdeck-agent-runtime/baseline-known-failure.txt create mode 100644 .omo/evidence/20260722-pilotdeck-agent-runtime/focused-tests.txt create mode 100644 .omo/evidence/20260722-pilotdeck-agent-runtime/regression-suite.txt diff --git a/.omo/evidence/20260722-pilotdeck-agent-runtime/README.md b/.omo/evidence/20260722-pilotdeck-agent-runtime/README.md new file mode 100644 index 00000000..61d7aa29 --- /dev/null +++ b/.omo/evidence/20260722-pilotdeck-agent-runtime/README.md @@ -0,0 +1,32 @@ +# PilotDeck Agent Runtime QA Evidence + +Date: 2026-07-22 + +## What Was Tested + +1. TypeScript production build with `pnpm run build`. +2. Forty-one focused tests covering dynamic context, mutable `PreModelRequest`, post-routing request rebuilds, domain activation, artifact validation/correction, session cleanup, prompt dispatch arbitration, cancellation, timeout, shutdown, and the public WebSocket trust boundary. +3. A deterministic local-gateway integration test that loads a real project plugin command from `.pilotdeck/plugins`, injects current-request context and a model patch, registers a required `.xlsx` artifact, observes the first validation failure, and accepts completion only after the correction request creates the artifact. +4. The complete compiled test inventory except the independently reproduced baseline timer file: 154 tests. +5. The repository's unmodified `pnpm test` command, including the known baseline timer file. +6. Diff hygiene with `git diff --check`, and a base comparison for the timer implementation and tests. + +## What Was Observed + +- `pnpm run build`: PASS. +- Focused runtime controls: PASS, 41/41. +- Local gateway/project plugin QA: PASS. Dynamic context was present on the first model request only; `maxOutputTokens` was patched to 1234; a missing `final.xlsx` produced an artifact correction request; completion followed creation of the required file. +- Full regression inventory excluding `dist/tests/network/fetch.spec.js`: PASS, 154/154. +- Unmodified `pnpm test`: 153 passed and 7 cancelled in `tests/network/fetch.spec.ts`; no test reported a failed assertion. The cancellation is independently reproducible on Node v22.22.0 because the only pending retry/timeout timers are `unref()`'d. +- `git diff --exit-code origin/main -- src/network/fetch.ts tests/network/fetch.spec.ts`: PASS, proving this branch does not modify the cancelled baseline surface. +- The user's main PilotDeck checkout retained its pre-existing uncommitted dynamic-context files; implementation and QA ran only in `/Users/da/Documents/PilotDeck-wt-agent-runtime`. + +## Why It Is Enough + +The focused suite proves state and security invariants at component boundaries. The local-gateway test additionally drives production assembly, project plugin discovery, command-hook execution, lifecycle parsing, context preparation, routing, model execution, artifact correction, transcript/session cleanup, and gateway disposal together. The 154-test regression pass covers every other compiled test surface in the repository. + +## What Was Omitted + +- No external model or network API was called; QA used a deterministic local fake model. +- No real API keys, auth headers, environment dumps, or private user data were captured. The integration fixture uses the literal placeholder `test-key`. +- The unrelated baseline network timer defect was not changed in this branch to keep the PR scoped to Agent runtime foundations. diff --git a/.omo/evidence/20260722-pilotdeck-agent-runtime/baseline-known-failure.txt b/.omo/evidence/20260722-pilotdeck-agent-runtime/baseline-known-failure.txt new file mode 100644 index 00000000..08b4fd58 --- /dev/null +++ b/.omo/evidence/20260722-pilotdeck-agent-runtime/baseline-known-failure.txt @@ -0,0 +1,27 @@ +COMMAND +pnpm test + +RESULT +exit_code=1 +tests=160 +pass=153 +fail=0 +cancelled=7 +cancelled_file=tests/network/fetch.spec.ts + +ERROR +Promise resolution is still pending but the event loop has already resolved + +BASE COMPARISON +git diff --exit-code origin/main -- src/network/fetch.ts tests/network/fetch.spec.ts +exit_code=0 + +ENVIRONMENT +node=v22.22.0 +pnpm=10.32.1 + +CAUSE +The baseline implementation calls unref() on the only retry and timeout timers. +Node can therefore end the test event loop while those promises are pending. +The same seven cancellations reproduce when this file is run alone. This branch +does not modify the implementation or its tests. diff --git a/.omo/evidence/20260722-pilotdeck-agent-runtime/focused-tests.txt b/.omo/evidence/20260722-pilotdeck-agent-runtime/focused-tests.txt new file mode 100644 index 00000000..71049f8d --- /dev/null +++ b/.omo/evidence/20260722-pilotdeck-agent-runtime/focused-tests.txt @@ -0,0 +1,32 @@ +COMMAND +pnpm run build && node --test --test-force-exit --test-timeout 60000 \ + dist/tests/agent/agent-loop-runtime-controls.spec.js \ + dist/tests/agent/agent-session-cleanup.spec.js \ + dist/tests/agent/local-gateway-runtime-controls.spec.js \ + dist/tests/artifact/artifact-validation.spec.js \ + dist/tests/context/dynamic-context-runtime.spec.js \ + dist/tests/context/dynamic-context-store.spec.js \ + dist/tests/extension/hooks/artifact-contract-output.spec.js \ + dist/tests/extension/hooks/domain-plugin-runtime.spec.js \ + dist/tests/extension/hooks/model-request-patch.spec.js \ + dist/tests/gateway/internal-prompt-dispatch-integration.spec.js \ + dist/tests/gateway/prompt-dispatch-gate.spec.js \ + dist/tests/gateway/public-submit-turn-boundary.spec.js \ + dist/tests/gateway/session-router-idle.spec.js \ + dist/tests/lifecycle/pre-model-request-effects.spec.js + +RESULT +exit_code=0 +tests=41 +pass=41 +fail=0 +cancelled=0 +duration_ms=770.365917 + +KEY OBSERVATIONS +- Project hook context was one-shot and survived a post-routing request rebuild. +- PreModelRequest token patches were preserved and clamped to model capability. +- Missing required artifacts triggered a bounded correction turn. +- Internal prompts waited for the authoritative session idle state and fully drained. +- Public WebSocket input could not spoof trusted in-process origin/signal fields. +- SessionEnd cleared context/contracts even when hooks threw. diff --git a/.omo/evidence/20260722-pilotdeck-agent-runtime/regression-suite.txt b/.omo/evidence/20260722-pilotdeck-agent-runtime/regression-suite.txt new file mode 100644 index 00000000..85ae8b0b --- /dev/null +++ b/.omo/evidence/20260722-pilotdeck-agent-runtime/regression-suite.txt @@ -0,0 +1,15 @@ +COMMAND +find dist/tests -type f \( -name '*.test.js' -o -name '*.spec.js' \) \ + ! -path '*/network/fetch.spec.js' -print0 \ + | xargs -0 node --test --test-force-exit --test-timeout 60000 + +RESULT +exit_code=0 +tests=154 +pass=154 +fail=0 +cancelled=0 +duration_ms=27165.563583 + +This is the complete compiled repository test inventory except the separately +reproduced baseline timer file documented in baseline-known-failure.txt. From cd5a5627f4b7221da34fc49fb149d652491e0823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 13:11:39 +0800 Subject: [PATCH 07/12] fix(spreadsheets): align scaffold flow with write guard --- skills/spreadsheets/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skills/spreadsheets/SKILL.md b/skills/spreadsheets/SKILL.md index 9fd0d6f9..8841bf78 100644 --- a/skills/spreadsheets/SKILL.md +++ b/skills/spreadsheets/SKILL.md @@ -95,6 +95,12 @@ Create one executable builder: bash "$SHEET" scaffold --out "$WORKSPACE/tmp/workbook.mjs" ``` +The scaffold command creates an existing file. Immediately call PilotDeck's +`read_file` tool with that exact builder path before changing it with +`write_file` or `edit_file`. Shell output such as `cat` does not establish the +file snapshot required by PilotDeck's write-safety guard and is not a +substitute for `read_file`. + Patch and rerun that builder instead of creating duplicate scripts. Build a net-new workbook: ```bash From 695b5a09c7564057f323d82019b3181d69064cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 13:11:49 +0800 Subject: [PATCH 08/12] fix(spreadsheets): normalize missing table styles --- .../spreadsheets/scripts/spreadsheet_cli.mjs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/skills/spreadsheets/scripts/spreadsheet_cli.mjs b/skills/spreadsheets/scripts/spreadsheet_cli.mjs index 28fcfeba..6702e4bc 100644 --- a/skills/spreadsheets/scripts/spreadsheet_cli.mjs +++ b/skills/spreadsheets/scripts/spreadsheet_cli.mjs @@ -160,16 +160,32 @@ async function loadXlsx(filePath) { const workbook = new ExcelJS.Workbook(); try { await workbook.xlsx.readFile(path.resolve(filePath)); - return workbook; + return normalizeMissingTableStyles(workbook); } catch (error) { const normalizedPackage = await normalizePrefixedSpreadsheetPackage(filePath); if (!normalizedPackage) throw error; const normalizedWorkbook = new ExcelJS.Workbook(); await normalizedWorkbook.xlsx.load(normalizedPackage); - return normalizedWorkbook; + return normalizeMissingTableStyles(normalizedWorkbook); } } +function normalizeMissingTableStyles(workbook) { + for (const worksheet of workbook.worksheets) { + for (const table of worksheet.model?.tables ?? []) { + if (table.style !== null && table.style !== undefined) continue; + table.style = { + theme: null, + showFirstColumn: false, + showLastColumn: false, + showRowStripes: false, + showColumnStripes: false, + }; + } + } + return workbook; +} + function inferScalar(value) { if (value === "") return ""; if (/^(?:true|false)$/i.test(value)) return value.toLowerCase() === "true"; From 5d0217041d947c7196a81c6b0396da062732cbdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 13:12:00 +0800 Subject: [PATCH 09/12] fix(agent): validate artifacts at max turns --- src/agent/loop/AgentLoop.ts | 27 +++++++ .../agent/agent-loop-runtime-controls.spec.ts | 72 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/agent/loop/AgentLoop.ts b/src/agent/loop/AgentLoop.ts index 32c700f1..5d91680a 100644 --- a/src/agent/loop/AgentLoop.ts +++ b/src/agent/loop/AgentLoop.ts @@ -1691,6 +1691,33 @@ export class AgentLoop { const nextTurnCount = turnCount + 1; if (input.maxTurns && nextTurnCount > input.maxTurns) { + const artifactValidation = this.dependencies.artifactValidation + ? await this.dependencies.artifactValidation.validate({ + sessionId: input.sessionId, + turnId: input.turnId, + workspaceRoot: this.config.cwd, + signal: input.abortSignal, + }) + : undefined; + if (artifactValidation && !artifactValidation.passed) { + const error = agentError("agent_unsupported_feature", formatArtifactCorrectionPrompt(artifactValidation)); + const result = this.createTurnResult(input, { + type: "error", + stopReason: "tool_error", + usage, + permissionDenials, + turns: turnCount, + startedAt, + finalMessage, + structuredOutput, + errors: [error], + }); + yield { type: "turn_failed", sessionId: input.sessionId, turnId: input.turnId, error }; + await captureTurn(true); + yield { type: "turn_completed", sessionId: input.sessionId, turnId: input.turnId, result }; + return { result, messages }; + } + const maxTurnsError = agentError( "agent_max_turns_reached", `Reached maximum number of turns (${input.maxTurns}).`, diff --git a/tests/agent/agent-loop-runtime-controls.spec.ts b/tests/agent/agent-loop-runtime-controls.spec.ts index f7a3302a..8c8087ec 100644 --- a/tests/agent/agent-loop-runtime-controls.spec.ts +++ b/tests/agent/agent-loop-runtime-controls.spec.ts @@ -138,6 +138,78 @@ test("artifact failure injects one bounded correction turn and succeeds after va } }); +test("required artifact failure wins over max-turn completion after a final tool call", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-agent-loop-artifact-max-turns-")); + try { + const contracts = new ArtifactContractStore(); + contracts.register("session-1", "domain-plugin", [{ + id: "final-workbook", + path: "deliverable.xlsx", + required: true, + expectedExtensions: [".xlsx"], + validatorIds: ["core:file-exists"], + }]); + const requests: CanonicalModelRequest[] = []; + const dependencies = createDependencies(requests, { + artifactValidation: new ArtifactValidationRuntime(contracts, [new FileExistsValidator()]), + router: { + async decide(input: { request: CanonicalModelRequest }): Promise { + return { + provider: input.request.provider, + model: input.request.model, + scenarioType: "default", + isSubagent: false, + orchestrating: false, + resolvedFrom: "scenario", + mutations: {}, + }; + }, + async *execute(_decision: RouterDecision, request: CanonicalModelRequest): AsyncIterable { + requests.push(request); + const toolCall = { id: "tool-1", name: "noop", input: {} }; + yield { type: "message_start", role: "assistant" }; + yield { type: "tool_call_start", id: toolCall.id, name: toolCall.name }; + yield { type: "tool_call_end", toolCall }; + yield { type: "message_end", finishReason: "tool_call" }; + }, + async *stream(): AsyncIterable { + throw new Error("stream fallback should not be used"); + }, + }, + tools: { + registry: new ToolRegistry(), + scheduler: { + async executeAll(calls) { + return calls.map((call) => ({ + type: "success" as const, + toolCallId: call.id, + toolName: call.name, + content: [{ type: "text" as const, text: "ok" }], + startedAt: "2026-07-22T00:00:00.000Z", + completedAt: "2026-07-22T00:00:00.000Z", + })); + }, + }, + }, + }); + const loop = new AgentLoop(createConfig(workspace), dependencies); + + const completed = await drainLoop(loop.run({ + sessionId: "session-1", + turnId: "turn-1", + messages: [userMessage("create the required deliverable")], + maxTurns: 1, + })); + + assert.equal(completed.result.type, "error"); + assert.equal(completed.result.stopReason, "tool_error"); + assert.match(completed.result.errors?.[0]?.message ?? "", /Artifact validation failed/u); + assert.match(completed.result.errors?.[0]?.message ?? "", /deliverable\.xlsx/u); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + function createDependencies( requests: CanonicalModelRequest[], options: Partial & { beforeResponse?: (requestIndex: number) => Promise } = {}, From ffa879766bf2e69ee337c0af3a3e76991d874aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 13:12:13 +0800 Subject: [PATCH 10/12] fix(network): keep awaited retry timers alive --- src/network/fetch.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/network/fetch.ts b/src/network/fetch.ts index aa14b1bb..d78efee2 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -66,9 +66,6 @@ export async function networkFetch( const timeout = options.timeoutMs && options.timeoutMs > 0 ? setTimeout(() => controller.abort(new NetworkFetchError("network_timeout", `Network request timed out after ${options.timeoutMs}ms.`)), options.timeoutMs) : undefined; - if (timeout && typeof timeout === "object" && "unref" in timeout) { - (timeout as NodeJS.Timeout).unref(); - } try { const response = await performFetch(input, { @@ -233,21 +230,13 @@ function parseRetryAfterHeader(headerValue: string | null | undefined): number | } function delay(ms: number, signal?: AbortSignal): Promise { - if (!signal) return new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - if (typeof timer === "object" && "unref" in timer) { - (timer as NodeJS.Timeout).unref(); - } - }); + if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); if (signal.aborted) return Promise.reject(new NetworkFetchError("network_abort", "Network retry aborted.", signal.reason)); return new Promise((resolve, reject) => { const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); - if (typeof timer === "object" && "unref" in timer) { - (timer as NodeJS.Timeout).unref(); - } const onAbort = () => { clearTimeout(timer); reject(new NetworkFetchError("network_abort", "Network retry aborted.", signal.reason)); From c204b67c84b719578999daaba96e7a0950144d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Wed, 22 Jul 2026 13:12:23 +0800 Subject: [PATCH 11/12] test(agent): record legal runtime QA evidence --- .../20260722-legal-runtime-e2e/README.md | 47 +++++++++++++++++ .../real-run-summary.json | 52 +++++++++++++++++++ .../regression-summary.json | 26 ++++++++++ .../spreadsheet-self-test-summary.json | 24 +++++++++ 4 files changed, 149 insertions(+) create mode 100644 .omo/evidence/20260722-legal-runtime-e2e/README.md create mode 100644 .omo/evidence/20260722-legal-runtime-e2e/real-run-summary.json create mode 100644 .omo/evidence/20260722-legal-runtime-e2e/regression-summary.json create mode 100644 .omo/evidence/20260722-legal-runtime-e2e/spreadsheet-self-test-summary.json diff --git a/.omo/evidence/20260722-legal-runtime-e2e/README.md b/.omo/evidence/20260722-legal-runtime-e2e/README.md new file mode 100644 index 00000000..feb9e0cd --- /dev/null +++ b/.omo/evidence/20260722-legal-runtime-e2e/README.md @@ -0,0 +1,47 @@ +# Legal Runtime E2E QA + +Date: 2026-07-22 + +Base: `7404ba5cc67ae5689ce5ace07ac8a35ef55b3522` + +## What Was Tested + +- Replayed one historical complaint-material organization task through the production `createLocalGateway` surface in an isolated workspace and isolated `PILOT_HOME`. +- Reconstructed 15 OCR/text attachments from the archived accepted input. The archive did not contain the original PDF/XLSX binaries. +- Loaded a temporary legal Domain Plugin with `UserPromptSubmit` dynamic context, one required XLSX artifact contract, a legal review Skill, and a `PreModelRequest` patch. +- Drove the bundled Spreadsheet Skill through scaffold, builder editing, build, inspect, audit, render, and final artifact placement. +- Independently imported the final workbook with Codex `@oai/artifact-tool`, inspected workbook/sheet/table structure, scanned formula errors, rendered every worksheet, and visually reviewed the renders. +- Ran the Spreadsheet Skill self-test and focused AgentLoop/artifact regression tests. +- Ran the complete test suite and isolated a pre-existing `networkFetch` cancellation failure exposed by the gate. + +## What Was Observed + +- The first bounded rerun loaded the legal Skill, read all 15 inputs once, and called `read_file` immediately after spreadsheet scaffold. It ended at `max_turns` without creating the required workbook. This reproduced a generic Harness defect: a final tool-calling turn could reach the max-turn branch before required artifact validation ran. +- A failing-first regression test returned `max_turns` on the old production path. After the fix, the same scenario returns `tool_error` with the missing artifact path. +- The final rerun completed normally with 17 model requests and 37 successful tool calls. It created the required workbook at the exact contracted path and ran inspect/audit/render before completion. +- Independent workbook inspection found three worksheets and three tables. Used ranges were `A1:I26`, `A1:G18`, and `A1:H26`. Formula-error search matched zero cells. +- Visual review found readable titles, headers, wrapped long text, risk colors, and no overlapping or clipped cells across all three worksheet renders and the eight-page print render. +- Spreadsheet self-test passed create, LibreOffice recalculation, inspect, prefixed-OOXML normalization, clean audit, existing-workbook edit-copy, intentional formula-error detection, CSV/TSV, compatibility preflight, and two-page render. +- The first complete suite run passed 155 tests but cancelled all seven `networkFetch` tests because awaited retry/timeout timers were unreferenced and allowed a short-lived Node process to exit. Removing `unref()` from timers owned by an awaited request changed the focused network result from 0/7 completed to 7/7 passed. +- The final complete suite passed 162/162 with zero failures, cancellations, or skips. + +## Why It Is Enough + +- The Gateway replay exercises dynamic prompt injection, plugin Skill loading, ToolGuard freshness, artifact contracts, max-turn termination, and spreadsheet delivery together on a real historical legal workload. +- The focused test pins the exact generic max-turn/artifact regression without depending on legal content. +- The Spreadsheet self-test exercises the missing-table-style round trip that previously crashed after LibreOffice recalculation. +- The existing network tests exercise retry success, retry-after bounds, error normalization, request timeout, and parent abort propagation without relying on external network access. +- Independent artifact-tool inspection and visual review do not rely on the PilotDeck agent's own success report. + +## What Was Omitted + +- Original legal text, model credentials, provider configuration, raw session JSONL, generated builder source, and the resulting legal workbook are intentionally excluded from git. +- Original PDF/XLSX visual fidelity was not tested because the archive contains only OCR/text conversions. +- The temporary legal Domain Plugin used 16,384 output tokens and 20 max turns for the successful run. These are domain workflow/test settings, not PilotDeck Core defaults. +- `context_budget` status remained based on the model catalog's 65,536-token default reservation before the `PreModelRequest` patch. This is conservative status/accounting behavior and is left for a separate lifecycle-budget change because it did not invalidate this delivery. + +## Cleanup + +- Both Gateway runs were disposed by the driver. +- No server, tmux session, browser context, container, or bound port was left running. +- All temporary inputs, isolated homes, generated workbooks, render outputs, and verifier files were moved from `/tmp` to the macOS Trash with explicit paths. The source archive was retained. No user PilotDeck config or main-worktree file was modified. diff --git a/.omo/evidence/20260722-legal-runtime-e2e/real-run-summary.json b/.omo/evidence/20260722-legal-runtime-e2e/real-run-summary.json new file mode 100644 index 00000000..5dc79d61 --- /dev/null +++ b/.omo/evidence/20260722-legal-runtime-e2e/real-run-summary.json @@ -0,0 +1,52 @@ +{ + "baseCommit": "7404ba5cc67ae5689ce5ace07ac8a35ef55b3522", + "source": { + "kind": "archived accepted-input replay", + "attachmentCount": 15, + "attachmentFormat": "OCR/text reconstruction", + "originalBinariesAvailable": false + }, + "boundedRun": { + "maxTurns": 12, + "modelRequests": 15, + "toolCalls": 32, + "finishReason": "max_turns", + "requiredArtifactCreated": false, + "observedFreshnessSequence": [ + "spreadsheet scaffold", + "read_file(builder)", + "write_file(builder)" + ], + "diagnosis": "max-turn termination after tool results bypassed required artifact validation" + }, + "finalRun": { + "maxTurns": 20, + "domainMaxOutputTokens": 16384, + "modelRequests": 17, + "toolCallsStarted": 37, + "toolCallsFinished": 37, + "finishReason": "completed", + "requiredArtifactCreated": true, + "workbook": { + "worksheetCount": 3, + "tableCount": 3, + "ranges": [ + "A1:I26", + "A1:G18", + "A1:H26" + ], + "formulaErrors": 0, + "pilotdeckRenderPages": 8, + "independentWorksheetRenders": 3, + "visualReview": "passed" + } + }, + "isolation": { + "pilotHome": "temporary isolated directory", + "workspace": "temporary isolated directory", + "memoryEnabled": false, + "telemetryEnabled": false, + "routerEnabled": false, + "mainWorktreeChanged": false + } +} diff --git a/.omo/evidence/20260722-legal-runtime-e2e/regression-summary.json b/.omo/evidence/20260722-legal-runtime-e2e/regression-summary.json new file mode 100644 index 00000000..13fe36eb --- /dev/null +++ b/.omo/evidence/20260722-legal-runtime-e2e/regression-summary.json @@ -0,0 +1,26 @@ +{ + "focused": { + "agentLoopArtifactAndNetwork": { + "passed": 17, + "failed": 0, + "cancelled": 0 + } + }, + "networkFailingFirst": { + "passed": 0, + "failed": 0, + "cancelled": 7, + "reason": "Promise resolution remained pending after unref timers allowed the Node event loop to exit" + }, + "networkAfterFix": { + "passed": 7, + "failed": 0, + "cancelled": 0 + }, + "fullSuiteAfterFix": { + "passed": 162, + "failed": 0, + "cancelled": 0, + "skipped": 0 + } +} diff --git a/.omo/evidence/20260722-legal-runtime-e2e/spreadsheet-self-test-summary.json b/.omo/evidence/20260722-legal-runtime-e2e/spreadsheet-self-test-summary.json new file mode 100644 index 00000000..699521c1 --- /dev/null +++ b/.omo/evidence/20260722-legal-runtime-e2e/spreadsheet-self-test-summary.json @@ -0,0 +1,24 @@ +{ + "status": "ok", + "steps": [ + "create", + "recalculate", + "inspect", + "inspect-prefixed-ooxml", + "audit-clean", + "edit-copy", + "audit-error", + "csv-tsv", + "compatibility-preflight", + "render" + ], + "formulaChecks": { + "margin": 0.3, + "projectedRevenue": 110000, + "formulaCount": 4, + "intentionalErrorDetected": true + }, + "tableCount": 1, + "renderPageCount": 2, + "visualReview": "passed" +} From 2b1b120c8e01f6c208cd376a1af63d47fe254d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 14:20:58 +0800 Subject: [PATCH 12/12] test(legal): record hard-case judge result Record only aggregate 321-checkpoint results; keep raw legal content and the Judge payload outside git. --- .../20260723-legal-hard-runtime/README.md | 2 +- .../judge-status.json | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.omo/evidence/20260723-legal-hard-runtime/README.md b/.omo/evidence/20260723-legal-hard-runtime/README.md index fbdd6176..3e81db53 100644 --- a/.omo/evidence/20260723-legal-hard-runtime/README.md +++ b/.omo/evidence/20260723-legal-hard-runtime/README.md @@ -45,4 +45,4 @@ Base under test: `7404ba5cc67ae5689ce5ace07ac8a35ef55b3522`, subsequently merged - `hard-quality-gate.json`: content-free independent artifact and coverage checks. - `regression-summary.json`: backend, UI, office-skill, and build results. - `known-ui-baselines.md`: failures reproduced from byte-identical `origin/main` files/configuration. -- `judge-status.json`: external Judge availability and claim boundary. +- `judge-status.json`: aggregate external Judge result and claim boundary. diff --git a/.omo/evidence/20260723-legal-hard-runtime/judge-status.json b/.omo/evidence/20260723-legal-hard-runtime/judge-status.json index 20e95bff..a58e2253 100644 --- a/.omo/evidence/20260723-legal-hard-runtime/judge-status.json +++ b/.omo/evidence/20260723-legal-hard-runtime/judge-status.json @@ -1,20 +1,38 @@ { "judge": "external 321-checkpoint semantic evaluator", - "status": "pending", + "status": "completed", "attempts": [ { "result": "failed", "errorClass": "UND_ERR_HEADERS_TIMEOUT" }, { - "result": "in_progress", + "result": "completed", "configuration": "custom dispatcher with bounded 45-minute headers/body timeout" } ], + "result": { + "ok": true, + "total": 321, + "passed": 224, + "failed": 97, + "passRate": 0.6978193146417445, + "criticalTotal": 141, + "criticalPassed": 117, + "criticalFailed": 24, + "criticalPassRate": 0.8297872340425532, + "nonCriticalTotal": 180, + "nonCriticalPassed": 107, + "nonCriticalFailed": 73, + "allPass": false, + "judgeErrors": 0 + }, "claims": { "harnessGatePassed": true, "manualArtifactQualityPassed": true, - "judgeCheckpointPassCountClaimed": 0 + "judgeCheckpointPassCountClaimed": 224, + "judgeCriticalCheckpointPassCountClaimed": 117, + "judgeAllPassClaimed": false }, "privacy": "Raw request, response, and legal checkpoint text are not stored in git." }