diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cc8eee2..00ff947 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -44,7 +44,9 @@ ### CI 完成确认 + `nmg-rcp forge-status --pr ` 是权威读法;下面两项是其补充。 + CI 从 push 到全部检查完成通常约一分半,可稍等后 `nmg-rcp forge-status --pr ` 复查, + 不要按固定时长人工反复轮询或把“看起来在跑”当作“已通过”。 --> - [ ] `nmg-rcp forge-status --pr ` 的 `All checks passed` 为 SUCCESS (或 CI Status Snapshot `.nmg-ci/status.json`:`conclusion: "success"` 且 `failures: []`) diff --git a/docs/design/agent-convergence-feedback-design.md b/docs/design/agent-convergence-feedback-design.md index 963ebfa..aa8e2a7 100644 --- a/docs/design/agent-convergence-feedback-design.md +++ b/docs/design/agent-convergence-feedback-design.md @@ -10,6 +10,40 @@ model, and the candidate design that would close the remaining loop. It is written so a future Agent can pick the thread up without re-deriving the conversation. +## Implementation boundary (partial, not design completion) + +The first code slice implements only: + +- `src/rcp/session-feedback.ts`: a validated receipt becomes a bounded temporary + tool observation in an explicitly named session/task frame. CLI opt-in uses + `reconcile ... --nmg optional --session-id --task-frame-id ` and an + already-running compatible daemon. It does not start/stop a daemon, write + durable memory, mark a quest complete, or train a router. Receipt delivery + failures are diagnostics, not changes to the verifier's decision. Plan mode + and idempotent receipt reuse do not emit a new notification. AG visibility + still follows the existing activation/disclosure lifecycle. +- `src/lab/context-router.ts`: an isolated 132-parameter action-value primitive + using the existing autodiff substrate, allowed-action selection, explicit + epsilon sampling with propensity, detached weight export, and observed-action + regression. It is not connected to automatic recall, not exposed as a new Lab + lease, and not a trained or enabled policy. Outcome admission remains the + caller's responsibility; arbitrary numeric rewards are not verified evidence. + +`tests/rcp/session-feedback.test.ts` exercises actual failed npm verification, +receipt/contract binding, duplicate projection, session isolation, outage +handling, plan behavior, and CLI-to-daemon HTTP delivery in a controlled fixture. +`tests/core/context-router.test.ts` covers the small head in interpreted and +compiled execution. These tests establish their exercised mechanisms, not +natural task success, convergence, or causal intervention benefit. + +Still open: the unified lifecycle/event and intervention sample pipeline, +versioned feature schema, four executor integration, validated outcome admission, +bookmark content ingestion, long-lived quests, held-out learning experiments, +and all default-activation gates. The original theoretical discussion below +remains a draft; in particular, lifecycle facts and SimHash are **not** admitted +as universally unbiased task rewards. Do not close the overall design task on +this slice or its PR. + ## 1. The replaced model Popular writing frames long agent tasks as a reliability product: if each step @@ -467,10 +501,10 @@ immutable checkpoints; authority (plan/apply) sizes the step. Existing: contract/observe/reconcile, idempotent re-observation, changedPaths, fail-closed termination, receipts. -Gaps: reconcile drift events do **not flow back** — neither into the session -AG as observations nor into searchable memory for a later session. Continuous -observation is deferred (correctly, until run-to-completion feedback latency is -demonstrated fatal). +Partial wiring: explicit CLI opt-in sends a newly recorded receipt into a named +session AG as bounded feedback (see the implementation boundary above). General +drift/lifecycle streaming, retry replay and cross-session memory admission remain +open. Continuous observation is deferred until its need is measured. ### 5.3 AG (session Active Graph runtime) @@ -485,8 +519,8 @@ projections (temporary, deduped, budgeted), typed edge layers that stop activation/reasoning from silently reinforcing semantic truth. Item kinds: `semantic_memory | tool_observation | board_projection | reasoning_artifact`. -Gaps: reconcile events are not wired into `observe()`; external content -fragments are not a first-class ingestible kind — AG ingests tool observations +Partial wiring: RCP receipts can reach `observe()` through the explicit optional +client bridge. External content fragments are not a first-class ingestible kind — AG ingests tool observations and board projections, but a bookmark (§5.4), an external snippet + path, cannot be added as content, and when a memory enters the AG as a `semantic_memory` item only its `statement` travels (its bookmarks do not ride @@ -580,7 +614,8 @@ elaborates and decomposes, it does not self-initiate). Consequences: half of this. 2. AG cannot ingest external content fragments; bookmark ingestion is the concrete first case (memory items enter the AG without their bookmarks). -3. Reconcile drift events are not wired into AG `observe()`. +3. Explicit newly recorded RCP receipt → AG `observe()` is implemented; general + drift streaming, notification replay and automatic harness feedback remain open. 4. No intervention logging or ε-greedy exploration exists to make `u(d,p)` estimable (counterfactual data). Reuse pattern: learned-router gates (`examples ≥ N`, lexical guard, shadow-first) plus an always-random probe diff --git a/docs/design/ci-cd-and-quality.md b/docs/design/ci-cd-and-quality.md index 19dfd22..ab089eb 100644 --- a/docs/design/ci-cd-and-quality.md +++ b/docs/design/ci-cd-and-quality.md @@ -405,6 +405,21 @@ revision,避免控制面输出改变自身输入。 当前只实现 run-to-completion 路径。`continuous` 是 Contract 可声明的权限上限,不表示 已经存在 watcher;在出现真实持续 contract 前,常驻 queue/catalog/watcher 仍明确延后。 +#### 可选的会话反馈 + +`reconcile` 可显式附加 `--nmg optional --session-id --task-frame-id `, +把本次已落盘的 receipt 投影到调用方指定的会话 AG。两项身份必须同时给出,不能以 +Contract ID 猜测会话。该路径只连接 `NMG_DATA_DIR` 下已经运行且协议/能力兼容的 +`nmg.sqlite` daemon,不启动、替换或停止它;也不执行普通 NMG recall 或发布黑板结果。 + +反馈保留 receipt ID、Contract digest、observed revision、检查结果与有界诊断,作为 +`tool_observation` 遵守既有 AG 激活、去重和会话清理规则。receipt 仍是完整证据源, +AG 摘要不是任务完成标签,也不会自动进入 LTG 或控制器训练。收据完整性与成功条件分开: +scope 越界的 `failed` 收据可以保存并反馈,但不能作为成功复用;`verified` 收据仍须 scope +匹配。RCP 通过随包 CLI 的 `nmg session observe` 通讯,不导入 NMG 内部模块。plan 和 receipt reuse +不产生新通知;反馈失败只记入 `memoryDiagnostics`,不会改写验证结论。当前边界不含 +通用事件总线、自动重试投递、跨会话任务续接或学习策略激活。 + ### 7.12 CI 状态观察 `.github/workflows/ci-status.yml` 在规范 `CI` workflow 完成后运行一个只读 GitHub-hosted diff --git a/docs/design/completion-audit.md b/docs/design/completion-audit.md index ba9e4b1..1238bf0 100644 --- a/docs/design/completion-audit.md +++ b/docs/design/completion-audit.md @@ -70,6 +70,22 @@ to use it explicitly for one suitable task. | Repository Control Plane | External Contract-to-Agent-to-Code-to-Verifier run-to-completion reconciliation with separate desired, observed, receipt and forge truth domains; optional one-way NMG integration | `src/rcp/`, `nmg-rcp` CLI, standard `agent:verify` auto-delegation for uniquely covered scopes, shared context/verification primitives, deterministic RCP tests, validated receipt reuse, verifier/route binding, fail-closed Git/forge provenance, local receipt scan, bounded process harness and explicit interrupted-attempt recovery | **Verified core** | The default receipt store and in-flight journal are local and Git-ignored: they support operator audit, idempotency and replay-safe recovery, not portable third-party proof. Unmatched scopes keep ordinary verification; overlapping Contracts fail closed. Continuous reconciliation and external attestation are explicitly deferred. | | Cloud and sandbox | Pluggable future integration only; no default dependency | explicit product boundary | **Out of scope** | Pi sandbox plugins own execution isolation; cloud waits for multi-device scope. | +## Agent convergence feedback implementation slice + +The [feedback design](agent-convergence-feedback-design.md) remains **partial**, +not completed by a documentation or implementation PR. `SessionFeedbackProvider` +and the explicit RCP CLI session/task target send validated, newly recorded +receipts into temporary AG observations. Controlled tests cover a real failing +check, HTTP delivery, identity binding, deduplication and failure isolation. +They do not prove overall task completion or natural Agent improvement. + +The 132-parameter `ContextRouter` numerical primitive has action-mask, sampling, +observed-action update and weight round-trip tests, including compiled execution. +It is not automatically invoked, trained from natural outcomes, or exposed as a +new self-service Lab capability. Unified event/sample collection, fixed feature +semantics, executor integration, outcome admission and held-out efficacy remain +open. No completed status or default promotion is implied. + ## Actual active blockers Four active work families remain; the first is narrower AG completion work and diff --git a/docs/design/design.md b/docs/design/design.md index 621d2d4..2ca2d99 100644 --- a/docs/design/design.md +++ b/docs/design/design.md @@ -50,7 +50,11 @@ NMG through an optional client adapter, but the dependency direction is strictly `Repository Control Plane -> NMG client -> NMG daemon`. Git contracts, repository observations, verification receipts, pull-request state, and merge readiness remain control-plane or forge concerns; they do not become STG/LTG truth and the -NMG daemon never schedules or reconciles repository work. The implemented core +NMG daemon never schedules or reconciles repository work. An explicit RCP client +may project a validated receipt into a named session/task frame as bounded +`tool_observation` feedback through the existing AG RPC. This does not infer +quest completion, admit a training reward, or transfer verification authority to +NMG; full evidence remains in the receipt. The implemented core and deferred boundary are specified in [ci-cd-and-quality.md](ci-cd-and-quality.md#7-repository-control-plane) and the implemented [Repository Control Plane decision](../decisions/implemented/2026-08-29-repository-control-plane.md). diff --git a/src/cli/commands.ts b/src/cli/commands.ts index f970ced..6728c10 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -26,6 +26,7 @@ import type { NmgRollbackNodeTransformParams, NmgRetentionCandidatesParams, NmgSearchParams, + NmgSessionActiveGraphParams, NmgSetStorageStateParams, NmgSplitNodeParams, NmgSyncStgParams, @@ -44,6 +45,8 @@ export interface CliCommandSpec { method?: NmgMethod; /** Local command dispatched by main.ts without RPC (e.g. the inspect TUI). */ local?: boolean; + /** Session-owned state is meaningful only in an existing resident daemon. */ + requiresResident?: boolean; /** Set false to reject COMMON_FLAGS (e.g. --json) for this command. */ includeCommonFlags?: boolean; /** CLI words, e.g. ["search"] or ["retention", "candidates"]. */ @@ -64,6 +67,25 @@ export const COMMON_OPTIONS = ["data-dir", "db"] as const; export const COMMON_FLAGS = ["json"] as const; export const NMG_CLI_COMMANDS: readonly CliCommandSpec[] = [ + { + method: "sessionActiveGraph", + words: ["session", "observe"], + requiresResident: true, + usageLine: + "nmg session observe TEXT --session-id ID --task-frame-id ID --source-id ID [--json]", + options: ["session-id", "task-frame-id", "source-id"], + flags: [], + usageDetail: + "Session observations require an already-running compatible daemon. They are temporary tool observations, not durable memories or completion labels.", + buildParams: (values): NmgSessionActiveGraphParams => ({ + action: "observe", + statement: singlePositional(values, "session observe"), + sessionId: requiredOption(values, "session-id"), + taskFrameId: requiredOption(values, "task-frame-id"), + sourceId: requiredOption(values, "source-id"), + kind: "tool_observation", + }), + }, { method: "lab", words: ["lab", "list"], diff --git a/src/cli/main.ts b/src/cli/main.ts index 7bbe5cf..b79f8a5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -42,7 +42,7 @@ import { NmgService } from "./service.ts"; import { histogramQuantile } from "../core/perf.ts"; import type { MemoryContext } from "../core/types.ts"; import { compactSearchContext } from "../integration/search-projection.ts"; -import { assertDaemonProtocol } from "./daemon-client.ts"; +import { assertDaemonCapability, assertDaemonProtocol, parseDaemonHello } from "./daemon-client.ts"; // The CLI surface (synopsis, option details, known options/flags) is // assembled from the command registry in commands.ts; only the daemon @@ -115,20 +115,7 @@ export async function runCli( } try { - const state = readServerState(serverStatePath(service.databasePath)); - let result: unknown; - if (state?.transport === "http" && isProcessAlive(state.pid)) { - if (state.protocol !== NMG_PROTOCOL_VERSION) { - assertDaemonProtocol((await httpCall(state, "hello")) as NmgHelloResult); - } - result = await httpCall( - state, - parsed.command, - (parsed.params ?? {}) as Record, - ); - } else { - result = await service.invoke(parsed.command, parsed.params); - } + const result = await invokeCliMethod(service, parsed.command, parsed); const output = parsed.compactJson && parsed.command === "search" ? compactSearchContext(result as MemoryContext) @@ -147,6 +134,29 @@ export async function runCli( } } +async function invokeCliMethod( + service: NmgService, + method: NmgMethod, + parsed: ParsedArguments, +): Promise { + const state = readServerState(serverStatePath(service.databasePath)); + if (state?.transport === "http" && isProcessAlive(state.pid)) { + if (parsed.requiresResident) { + const hello = parseDaemonHello(await httpCall(state, "hello")); + assertDaemonCapability( + new Set(hello.capabilities), + method, + hello.methods ? new Set(hello.methods) : undefined, + ); + } else if (state.protocol !== NMG_PROTOCOL_VERSION) { + assertDaemonProtocol((await httpCall(state, "hello")) as NmgHelloResult); + } + return httpCall(state, method, parsed.params ?? {}); + } + if (parsed.requiresResident) throw new Error("session observations require a running NMG daemon"); + return service.invoke(method, parsed.params); +} + async function runDaemonCommand( command: DaemonCommand, service: NmgService, @@ -306,6 +316,7 @@ interface ParsedArguments { | NmgPerfParams; json: boolean; compactJson?: boolean; + requiresResident?: boolean; dataDirectory?: string; databasePath?: string; /** Local `graph` command: output HTML path. */ @@ -365,6 +376,7 @@ function parseArguments(argv: readonly string[]): ParsedArguments { } return { command: spec.method!, + requiresResident: spec.requiresResident, params: spec.buildParams(values) as ParsedArguments["params"], json: values.flags.has("json"), compactJson: values.flags.has("compact-json"), diff --git a/src/lab/context-router.ts b/src/lab/context-router.ts new file mode 100644 index 0000000..ab03030 --- /dev/null +++ b/src/lab/context-router.ts @@ -0,0 +1,133 @@ +import { Tensor, gradientStep } from "./autodiff.ts"; + +export const CONTEXT_ACTIONS = ["none", "cue", "resurface", "retrieve"] as const; +export type ContextAction = (typeof CONTEXT_ACTIONS)[number]; +const FEATURE_COUNT = 32; +const ACTION_COUNT = CONTEXT_ACTIONS.length; + +function validateSelection( + allowed: readonly ContextAction[], + costs: readonly number[], + epsilon: number, +): void { + if (!allowed.length || allowed.some((action) => !CONTEXT_ACTIONS.includes(action))) { + throw new Error("at least one valid allowed action is required"); + } + if (costs.length !== ACTION_COUNT || costs.some((x) => !Number.isFinite(x) || x < 0)) { + throw new Error("four finite nonnegative costs are required"); + } + if (!Number.isFinite(epsilon) || epsilon < 0 || epsilon > 1) { + throw new Error("epsilon must be in [0, 1]"); + } +} + +/** Experimental action-value head, not an executor or an authorization policy. + * Callers own normalized pre-action features, attributable outcome admission, + * task/version isolation, and the permitted action set. No default activation, + * implicit learning, neural experts, memory writes, or model calls occur here. + */ +export class ContextRouter { + readonly #weights: Tensor; + readonly #bias: Tensor; + + constructor(parameters: readonly number[] = new Array(132).fill(0)) { + if (parameters.length !== 132 || parameters.some((x) => !Number.isFinite(Math.fround(x)))) { + throw new Error("router requires 132 finite Float32 parameters"); + } + this.#weights = Tensor.matrix(parameters.slice(0, 128), ACTION_COUNT, FEATURE_COUNT, true); + this.#bias = Tensor.vector(parameters.slice(128), true); + } + + parameters(): number[] { + return [...this.#weights.data, ...this.#bias.data]; + } + + values(features: readonly number[]): number[] { + const values = [...this.#forward(features).data]; + if (values.some((x) => !Number.isFinite(x))) throw new Error("non-finite router prediction"); + return values; + } + + /** Costs are already converted by the caller to reward units (lambda * K). + * Ties follow CONTEXT_ACTIONS order, preferring none when it is permitted. + * Exploration is a real sample, not a shadow counterfactual observation. + */ + select( + features: readonly number[], + allowed: readonly ContextAction[], + costs: readonly number[] = [0, 0, 0, 0], + epsilon = 0, + random: () => number = Math.random, + ): { action: ContextAction; probability: number } { + validateSelection(allowed, costs, epsilon); + const values = this.values(features); + const indices = CONTEXT_ACTIONS.map((_, i) => i).filter((i) => + allowed.includes(CONTEXT_ACTIONS[i]!), + ); + const greedy = indices.reduce((best, i) => + values[i]! - costs[i]! > values[best]! - costs[best]! ? i : best, + ); + const draw = random(); + if (!Number.isFinite(draw) || draw < 0 || draw >= 1) + throw new Error("random must be in [0, 1)"); + let cumulative = 0; + for (const i of indices) { + const probability = epsilon / indices.length + (i === greedy ? 1 - epsilon : 0); + cumulative += probability; + if (draw < cumulative || i === indices.at(-1)) { + return { action: CONTEXT_ACTIONS[i]!, probability }; + } + } + throw new Error("no allowed action"); + } + + /** Supervised regression for ONE actually executed, outcome-labelled action. + * This is not a causal estimator or off-policy evaluation. Never feed shadow + * choices, unverified completion claims, or unexecuted actions as labels. + */ + update( + features: readonly number[], + action: ContextAction, + reward: number, + learningRate: number, + ): number { + const index = CONTEXT_ACTIONS.indexOf(action); + if (index < 0) throw new Error("unknown context action"); + if (!Number.isFinite(reward) || Math.abs(reward) > 1) + throw new Error("reward must be in [-1, 1]"); + if (!Number.isFinite(learningRate) || learningRate <= 0 || learningRate > 1) { + throw new Error("learning rate must be in (0, 1]"); + } + const prediction = this.#forward(features); + // A fixed-shape mask avoids varying Index op arguments in the compiled tape. + const mask = Tensor.vector(CONTEXT_ACTIONS.map((_, i) => (i === index ? 1 : 0))); + const target = Tensor.vector(CONTEXT_ACTIONS.map(() => reward)); + const error = prediction.subtract(target); + const loss = error.multiply(error).multiply(mask).sum().multiplyScalar(0.5); + const value = loss.scalarValue; + if (!Number.isFinite(value)) throw new Error("non-finite router loss"); + loss.backward(); + const parameters = [this.#weights, this.#bias]; + for (const parameter of parameters) { + if ( + parameter.grad.some( + (g, i) => !Number.isFinite(Math.fround(parameter.data[i]! - learningRate * g)), + ) + ) { + throw new Error("non-finite router update"); + } + } + gradientStep(parameters, learningRate); + return value; + } + + #forward(features: readonly number[]): Tensor { + if ( + features.length !== FEATURE_COUNT || + features.some((x) => !Number.isFinite(x) || Math.abs(x) > 1) + ) { + throw new Error("router requires 32 finite features normalized to [-1, 1]"); + } + return this.#weights.matmul(Tensor.vector(features)).add(this.#bias); + } +} diff --git a/src/rcp/cli/main.ts b/src/rcp/cli/main.ts index def310e..5e52146 100644 --- a/src/rcp/cli/main.ts +++ b/src/rcp/cli/main.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { compileContractFile } from "../contract.ts"; import { planWorkOrder, readRouteDeclarations } from "../planner.ts"; @@ -17,7 +18,8 @@ import { import { validateReceipt } from "../receipt.ts"; import { reconcileOnce } from "../reconcile.ts"; import { LocalRepositoryProvider } from "../repository.ts"; -import type { NmgMemoryClient } from "../providers.ts"; +import { SessionFeedbackProvider, type SessionFeedbackTarget } from "../session-feedback.ts"; +import type { MemoryProvider, NmgMemoryClient } from "../providers.ts"; import type { RepositoryContractIr, RepositoryReceipt } from "../types.ts"; interface CliOptions { @@ -40,6 +42,8 @@ interface CliOptions { headRef?: string; title?: string; body?: string; + sessionId?: string; + taskFrameId?: string; } interface CliOptionState extends Omit { @@ -222,7 +226,18 @@ async function reconcileContract( harness, verifier: new LocalNpmVerifierProvider(), receipts: new FileReceiptSink(options.receiptDirectory), - memory: memoryProvider(options.nmgMode, options.root), + memory: memoryProvider( + options.nmgMode, + options.root, + options.sessionId && options.taskFrameId + ? { + sessionId: options.sessionId, + taskFrameId: options.taskFrameId, + contractId: contract.id, + contractDigest: contract.contractDigest, + } + : undefined, + ), forge: options.pullRequestNumber === undefined ? undefined : new GitHubForgeProvider(), }, ); @@ -272,6 +287,15 @@ const valueOptionHandlers: Record< "--head": (state, value) => (state.headRef = value), "--title": (state, value) => (state.title = value), "--body": (state, value) => (state.body = value), + "--session-id": (state, value) => { + if (!value?.trim() || value.startsWith("--")) throw new Error("--session-id requires a value"); + state.sessionId = value.trim(); + }, + "--task-frame-id": (state, value) => { + if (!value?.trim() || value.startsWith("--")) + throw new Error("--task-frame-id requires a value"); + state.taskFrameId = value.trim(); + }, "--harness-command": (state, value) => (state.harnessCommand = value), "--harness-arg": (state, value) => state.harnessArgs.push(value ?? ""), }; @@ -325,6 +349,17 @@ function consumeNmgMode(args: string[], index: number, state: CliOptionState): n } function resolveCliOptions(state: CliOptionState): CliOptions { + if ( + (state.sessionId || state.taskFrameId) && + (!state.sessionId || + !state.taskFrameId || + state.nmgMode === "disabled" || + state.command !== "reconcile") + ) { + throw new Error( + "session feedback requires reconcile, --session-id, --task-frame-id, and --nmg optional|required", + ); + } const root = resolve(state.root); return { ...state, @@ -334,7 +369,11 @@ function resolveCliOptions(state: CliOptionState): CliOptions { }; } -function memoryProvider(mode: CliOptions["nmgMode"], root: string): NmgMemoryProvider | undefined { +function memoryProvider( + mode: CliOptions["nmgMode"], + root: string, + target?: SessionFeedbackTarget, +): MemoryProvider | undefined { if (mode === "disabled") return undefined; const client: NmgMemoryClient = { recall: async (query) => { @@ -380,7 +419,33 @@ function memoryProvider(mode: CliOptions["nmgMode"], root: string): NmgMemoryPro }); if (result.status !== 0) throw new Error("required NMG provider is unavailable"); } - return new NmgMemoryProvider(client); + const memory = new NmgMemoryProvider(client); + if (!target) return memory; + const feedback = new SessionFeedbackProvider(target, async (observation) => { + // The resident-only CLI owns protocol negotiation. RCP does not import NMG + // internals or start/stop its daemon. A bounded child also bounds outages. + const launcher = fileURLToPath(new URL("../../../bin/nmg.mjs", import.meta.url)); + await promisify(execFile)( + process.execPath, + [ + launcher, + "session", + "observe", + observation.statement, + "--session-id", + observation.sessionId, + "--task-frame-id", + observation.taskFrameId, + "--source-id", + observation.sourceId, + "--json", + ], + { cwd: root, timeout: 10_000, windowsHide: true, maxBuffer: 64 * 1024 }, + ); + }); + // Explicit session feedback is observation-only: do not launch the ordinary + // recall CLI (which can start a daemon or acquire embeddings) as a side effect. + return feedback; } function runNmg(root: string, args: string[]): string { @@ -471,6 +536,8 @@ Options: --title Draft PR title (default Contract intent) --body optional prose before the machine Contract binding --nmg disabled (default), optional, or required + --session-id opt into receipt feedback to this existing NMG session + --task-frame-id explicit feedback task frame (requires --session-id and NMG) `; const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; diff --git a/src/rcp/receipt.ts b/src/rcp/receipt.ts index 21579bf..e5ea833 100644 --- a/src/rcp/receipt.ts +++ b/src/rcp/receipt.ts @@ -10,7 +10,6 @@ export function validateReceipt(receipt: RepositoryReceipt): ReceiptValidation { const errors: string[] = []; validateReceiptIdentity(receipt, errors); validateWorkOrderBinding(receipt, errors); - if (!receipt.scope.matched) errors.push("actual scope does not match declared scope"); if (receipt.decision === "verified") validateVerifiedDecision(receipt, errors); const expectedId = receiptId({ ...receipt, receiptId: "" }); if (receipt.receiptId !== expectedId) errors.push("receiptId does not match canonical content"); @@ -49,6 +48,7 @@ function validateWorkOrderBinding(receipt: RepositoryReceipt, errors: string[]): } function validateVerifiedDecision(receipt: RepositoryReceipt, errors: string[]): void { + if (!receipt.scope.matched) errors.push("actual scope does not match declared scope"); if (receipt.harness.status !== "completed") { errors.push("verified receipt has incomplete harness"); } diff --git a/src/rcp/session-feedback.ts b/src/rcp/session-feedback.ts new file mode 100644 index 0000000..bb6565b --- /dev/null +++ b/src/rcp/session-feedback.ts @@ -0,0 +1,105 @@ +import type { MemoryProvider } from "./providers.ts"; +import { validateReceipt } from "./receipt.ts"; +import type { RepositoryReceipt } from "./types.ts"; + +export interface SessionFeedbackTarget { + sessionId: string; + taskFrameId: string; + contractId: string; + contractDigest: string; +} + +/** Narrow outbound client port, not an import of the daemon or its protocol. */ +interface Observation { + action: "observe"; + sessionId: string; + taskFrameId: string; + kind: "tool_observation"; + sourceId: string; + statement: string; +} + +/** RCP -> optional NMG client boundary. The caller supplies an explicit target; + * a Contract id is not a session identity. Receipts remain the source of truth. + * No task completion label, training update, board entry or durable memory is + * inferred from this temporary, bounded feedback projection. + */ +export class SessionFeedbackProvider implements MemoryProvider { + readonly descriptor = { + id: "rcp-session-feedback", + version: "1", + capabilities: ["optional-session-observation"], + operations: ["notify"], + authority: [] as [], + }; + readonly #target: Readonly; + readonly #observe: (observation: Observation) => Promise; + + constructor( + target: SessionFeedbackTarget, + observe: (observation: Observation) => Promise, + ) { + if (Object.values(target).some((value) => !value.trim())) { + throw new Error( + "session feedback requires explicit session, task frame, and contract identity", + ); + } + this.#target = Object.freeze({ ...target }); + this.#observe = observe; + } + + async notify({ receipt }: { receipt: RepositoryReceipt }): Promise { + const validation = validateReceipt(receipt); + if (!validation.valid) + throw new Error(`invalid feedback receipt: ${validation.errors.join("; ")}`); + if ( + receipt.contractId !== this.#target.contractId || + receipt.contractDigest !== this.#target.contractDigest + ) { + throw new Error("feedback receipt belongs to another contract revision"); + } + await this.#observe({ + action: "observe", + sessionId: this.#target.sessionId, + taskFrameId: this.#target.taskFrameId, + kind: "tool_observation", + sourceId: `rcp-receipt:${receipt.receiptId}`, + statement: renderFeedback(receipt), + }); + } +} + +function renderFeedback(receipt: RepositoryReceipt): string { + const checks = receipt.checks.slice(0, 8).map((check) => ({ + name: check.name.slice(0, 160), + status: check.status, + reason: check.reason?.slice(0, 400), + evidence: check.evidence?.slice(0, 400), + })); + const event = { + kind: "repository-verification-feedback", + receiptId: receipt.receiptId, + contractId: receipt.contractId.slice(0, 160), + contractDigest: receipt.contractDigest, + observedRevision: receipt.observedRevisionAfter, + decision: receipt.decision, + scopeMatched: receipt.scope.matched, + checks, + omittedChecks: Math.max(0, receipt.checks.length - checks.length), + diagnostics: receipt.diagnostics.slice(0, 4).map((value) => value.slice(0, 400)), + interpretation: + "Scoped verification observation, not proof of overall task completion. Consult the original receipt for full evidence.", + }; + let statement = JSON.stringify(event); + while (statement.length > 12_000 && (event.diagnostics.length || event.checks.length)) { + if (event.diagnostics.length) event.diagnostics.pop(); + else { + event.checks.pop(); + event.omittedChecks += 1; + } + statement = JSON.stringify(event); + } + if (statement.length > 12_000) + throw new Error("feedback receipt identity exceeds observation budget"); + return statement; +} diff --git a/tests/cli/session-observe.test.ts b/tests/cli/session-observe.test.ts new file mode 100644 index 0000000..f2e5ab9 --- /dev/null +++ b/tests/cli/session-observe.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +import { runCli } from "../../src/cli/main.ts"; +import { testWorkspace, withTestRuntime } from "../support/test-runtime.ts"; +import { join } from "node:path"; + +const quiet = { stdout: { write: () => true }, stderr: { write: () => true } }; + +test("session observe rejects absent resident daemon instead of losing ephemeral state", async () => { + await withTestRuntime([testWorkspace()], async (runtime) => { + const path = join(runtime.workspace().path, "nmg.sqlite"); + const result = await runCli( + [ + "session", + "observe", + "verifier failed", + "--session-id", + "owner", + "--task-frame-id", + "task-A", + "--source-id", + "receipt-1", + "--db", + path, + ], + quiet, + ); + assert.equal(result, 1); + assert.equal(existsSync(path), false); + assert.equal(existsSync(`${path}.server.json`), false); + }); +}); + +test("session observe requires explicit source, session and task identities", async () => { + assert.equal(await runCli(["session", "observe", "feedback"], quiet), 2); + assert.equal( + await runCli( + ["session", "observe", "feedback", "--session-id", "owner", "--task-frame-id", "task-A"], + quiet, + ), + 2, + ); +}); diff --git a/tests/core/context-router.test.ts b/tests/core/context-router.test.ts new file mode 100644 index 0000000..4597795 --- /dev/null +++ b/tests/core/context-router.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ContextRouter, CONTEXT_ACTIONS } from "../../src/lab/context-router.ts"; + +const features = Array.from({ length: 32 }, (_, i) => (i === 0 ? 1 : 0)); + +test("router is a 132-parameter gate; permissions and costs remain external", () => { + const router = new ContextRouter(); + assert.equal(router.parameters().length, 132); + assert.deepEqual(router.values(features), [0, 0, 0, 0]); + assert.equal(router.select(features, ["none", "retrieve"], [0, 0, 0, 1]).action, "none"); + assert.equal(router.select(features, ["retrieve"]).action, "retrieve"); + assert.throws(() => router.select(features, []), /allowed/); +}); + +test("observed-action regression never treats unexecuted actions as failures", () => { + const router = new ContextRouter(); + router.update(features, "retrieve", 1, 0.1); + const values = router.values(features); + assert.deepEqual(values.slice(0, 3), [0, 0, 0]); + assert.ok(values[3]! > 0); + router.update(features, "cue", -1, 0.1); + assert.ok(router.values(features)[1]! < 0); + assert.equal(router.values(features)[3], values[3]); +}); + +test("epsilon exploration reports the probability of the actually sampled action", () => { + const router = new ContextRouter(); + const selected = router.select(features, ["none", "retrieve"], undefined, 0.2, () => 0.95); + assert.equal(selected.action, "retrieve"); + assert.ok(Math.abs(selected.probability - 0.1) < 1e-12); + const greedy = router.select(features, ["none", "retrieve"], undefined, 0.2, () => 0); + assert.equal(greedy.probability, 0.9); + assert.equal(CONTEXT_ACTIONS.length, 4); +}); + +test("parameter snapshots are detached and round-trip without changing predictions", () => { + const router = new ContextRouter(); + router.update(features, "resurface", 0.7, 0.1); + const weights = router.parameters(); + const restored = new ContextRouter(weights); + assert.deepEqual(restored.values(features), router.values(features)); + weights.fill(99); + assert.deepEqual(restored.values(features), router.values(features)); +}); + +test("changing samples/actions agree with finite-difference loss gradients", () => { + const router = new ContextRouter(Array.from({ length: 132 }, (_, i) => ((i % 7) - 3) * 0.01)); + for (let step = 0; step < 8; step++) { + const x = Array.from({ length: 32 }, (_, i) => (((i + step) % 5) - 2) / 4); + const action = CONTEXT_ACTIONS[step % 4]!; + const index = step % 4; + const reward = step % 2 ? 0.3 : -0.6; + const before = router.parameters(); + const loss = (weights: number[]) => { + const prediction = x.reduce( + (sum, value, i) => sum + value * weights[index * 32 + i]!, + weights[128 + index]!, + ); + return 0.5 * (prediction - reward) ** 2; + }; + const numeric = before.map((_, i) => { + const plus = [...before]; + const minus = [...before]; + plus[i]! += 0.001; + minus[i]! -= 0.001; + return (loss(plus) - loss(minus)) / 0.002; + }); + const measured = router.update(x, action, reward, 0.05); + assert.ok(Math.abs(measured - loss(before)) < 1e-6); + const after = router.parameters(); + for (let i = 0; i < 132; i++) { + assert.ok( + Math.abs(after[i]! - (before[i]! - 0.05 * numeric[i]!)) < 1e-6, + `step ${step}, parameter ${i}`, + ); + } + } +}); + +test("invalid numerical inputs are rejected before parameters change", () => { + const router = new ContextRouter(); + const before = router.parameters(); + assert.throws(() => router.update(features, "cue", NaN, 0.1), /reward/); + assert.throws(() => router.update(features, "cue", 1, Infinity), /learning rate/); + assert.throws(() => router.values([1]), /32/); + assert.throws(() => router.values(features.map(() => Infinity)), /features/); + assert.throws(() => router.select(features, ["none"], [0, 0, 0, NaN]), /cost/); + assert.throws(() => router.select(features, ["none"], undefined, 2), /epsilon/); + assert.throws(() => router.select(features, ["none"], undefined, 0, () => 1), /random/); + assert.deepEqual(router.parameters(), before); +}); diff --git a/tests/rcp/session-feedback.test.ts b/tests/rcp/session-feedback.test.ts new file mode 100644 index 0000000..def11fa --- /dev/null +++ b/tests/rcp/session-feedback.test.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { httpCall } from "../../src/cli/http-client.ts"; +import { serverStatePath } from "../../src/cli/lifecycle.ts"; +import type { NmgSessionActiveGraphResult } from "../../src/cli/protocol.ts"; +import { SessionActiveGraphRuntime } from "../../src/core/session-active-graph.ts"; +import { runRcpCli } from "../../src/rcp/cli/main.ts"; +import { + testWorkspace, + testDatabase, + testDaemon, + withTestRuntime, +} from "../support/test-runtime.ts"; +import { compileContract } from "../../src/rcp/contract.ts"; +import { readRouteDeclarations } from "../../src/rcp/planner.ts"; +import { + DefaultPolicyProvider, + ExternalWorkspaceHarnessProvider, + FileReceiptSink, + LocalNpmVerifierProvider, +} from "../../src/rcp/providers.ts"; +import { reconcileOnce } from "../../src/rcp/reconcile.ts"; +import { receiptId, validateReceipt } from "../../src/rcp/receipt.ts"; +import { LocalRepositoryProvider } from "../../src/rcp/repository.ts"; +import { SessionFeedbackProvider } from "../../src/rcp/session-feedback.ts"; +import { contractText, repositoryFixture } from "./fixture.ts"; + +function fixture(t: test.TestContext, failing = false) { + const root = repositoryFixture(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const text = failing + ? contractText().replace("checks: [check]", "checks: [failing]") + : contractText(); + const contract = compileContract({ text, path: join(root, "contract.yaml") }).contract!; + const runtime = new SessionActiveGraphRuntime(); + const target = { + sessionId: "owner", + taskFrameId: "quest-A", + contractId: contract.id, + contractDigest: contract.contractDigest, + }; + const memory = new SessionFeedbackProvider(target, async (observation) => + runtime.observe(observation), + ); + const providers = { + repository: new LocalRepositoryProvider(), + policy: new DefaultPolicyProvider(), + harness: new ExternalWorkspaceHarnessProvider(), + verifier: new LocalNpmVerifierProvider(30_000), + receipts: new FileReceiptSink(join(root, ".rcp", "receipts")), + memory, + }; + const request = { + root, + contract, + routes: readRouteDeclarations(root), + requestedMode: "apply" as const, + }; + return { root, contract, runtime, target, memory, providers, request }; +} + +test("real failing verification feeds bounded, attributed feedback into only its target AG", async (t) => { + const f = fixture(t, true); + const result = await reconcileOnce(f.request, f.providers); + assert.equal(result.status, "failed"); + assert.ok(result.receipt); + assert.deepEqual(result.memoryDiagnostics, []); + const snapshot = f.runtime.activateTemporaryProjection("owner"); + assert.ok(snapshot); + assert.equal(snapshot.items.length, 1); + const item = snapshot.items[0]!; + assert.equal(item.kind, "tool_observation"); + assert.equal(item.taskFrameId, "quest-A"); + assert.equal(item.sourceId, `rcp-receipt:${result.receipt.receiptId}`); + const event = JSON.parse(item.statement); + assert.equal(event.observedRevision, result.receipt.observedRevisionAfter); + assert.ok(event.checks.some((check: { status: string }) => check.status === "failed")); + assert.match(event.interpretation, /not proof of overall task completion/); + assert.equal(f.runtime.snapshot("other-session"), null); + await f.memory.notify({ receipt: result.receipt }); + assert.equal(f.runtime.snapshot("owner")!.items.length, 1, "retry deduplicates the same receipt"); + f.runtime.release("owner"); + assert.equal(f.runtime.snapshot("owner"), null); +}); + +test("scope violations remain visible feedback, not admissible success receipts", async (t) => { + const f = fixture(t); + writeFileSync(join(f.root, "outside.txt"), "out-of-scope change"); + const result = await reconcileOnce(f.request, f.providers); + assert.equal(result.status, "failed"); + assert.equal(result.receipt?.scope.matched, false); + assert.ok(result.receiptPath, "a valid failure receipt must be recorded"); + assert.ok(result.receipt); + assert.equal(validateReceipt(result.receipt).valid, true); + assert.equal( + await f.providers.receipts.find(result.receipt.operationIdentity), + null, + "failed receipts cannot certify a successful retry", + ); + const forgedSuccess = { ...result.receipt, decision: "verified" as const }; + forgedSuccess.receiptId = receiptId(forgedSuccess); + assert.equal( + validateReceipt(forgedSuccess).valid, + false, + "a fresh hash does not authorize scope violations", + ); + assert.deepEqual(result.memoryDiagnostics, []); + const snapshot = f.runtime.activateTemporaryProjection("owner"); + const event = JSON.parse(snapshot!.items[0]!.statement); + assert.equal(event.scopeMatched, false); +}); + +test("escaped diagnostic content stays bounded without emitting truncated JSON", async (t) => { + const f = fixture(t); + const result = await reconcileOnce(f.request, { ...f.providers, memory: undefined }); + assert.ok(result.receipt); + const receipt = structuredClone(result.receipt); + receipt.checks = Array.from({ length: 8 }, () => ({ + name: "check", + status: "passed" as const, + durationMs: 0, + evidence: "\u0001".repeat(800), + reason: "\u0001".repeat(800), + })); + receipt.receiptId = receiptId(receipt); + await f.memory.notify({ receipt }); + const item = f.runtime.activateTemporaryProjection("owner")!.items[0]!; + assert.ok(item.statement.length <= 12_000); + assert.ok(JSON.parse(item.statement).omittedChecks > 0); +}); + +test("receipt mismatch or tampering cannot enter the target working set", async (t) => { + const f = fixture(t); + const result = await reconcileOnce(f.request, { ...f.providers, memory: undefined }); + assert.ok(result.receipt); + const other = new SessionFeedbackProvider( + { ...f.target, contractDigest: "another-revision" }, + async (input) => f.runtime.observe(input), + ); + await assert.rejects(other.notify({ receipt: result.receipt }), /another contract revision/); + await assert.rejects( + f.memory.notify({ receipt: { ...result.receipt, decision: "failed" } }), + /invalid feedback receipt/, + ); + assert.equal(f.runtime.snapshot("owner"), null); +}); + +test("feedback outage is diagnostic and cannot veto independently verified work", async (t) => { + const f = fixture(t); + const memory = new SessionFeedbackProvider(f.target, async () => { + throw new Error("feedback unavailable"); + }); + const result = await reconcileOnce(f.request, { ...f.providers, memory }); + assert.equal(result.status, "verified"); + assert.match(result.memoryDiagnostics.join(" "), /feedback unavailable/); + assert.ok(result.receiptPath); +}); + +test("CLI sends real receipt feedback through authenticated HTTP without owning daemon lifecycle", async (t) => { + const f = fixture(t, true); + const contractPath = join(f.root, "contract.yaml"); + writeFileSync(contractPath, contractText().replace("checks: [check]", "checks: [failing]")); + await withTestRuntime([testWorkspace(), testDatabase(), testDaemon()], async (runtime) => { + const state = runtime.daemon().state; + writeFileSync(serverStatePath(runtime.database().path), JSON.stringify(state)); + const previous = process.env.NMG_DATA_DIR; + process.env.NMG_DATA_DIR = runtime.workspace().path; + try { + // Run an actual failing check; its receipt must reach the real HTTP AG. + const code = await runRcpCli([ + "reconcile", + contractPath, + "--root", + f.root, + "--apply", + "--workspace-ready", + "--nmg", + "optional", + "--session-id", + "http-owner", + "--task-frame-id", + "quest-http", + ]); + assert.equal(code, 1); + const result = (await httpCall(state, "sessionActiveGraph", { + action: "activate", + sessionId: "http-owner", + })) as NmgSessionActiveGraphResult; + assert.equal(result.action, "activate"); + if (result.action !== "activate") throw new Error("unexpected result"); + assert.equal(result.snapshot?.items.length, 1); + assert.equal(result.snapshot?.items[0]?.taskFrameId, "quest-http"); + assert.match(result.snapshot?.items[0]?.statement ?? "", /repository-verification-feedback/); + // It remains available; neither shutdown nor release is implicit in RCP. + assert.ok(await httpCall(state, "hello")); + } finally { + if (previous === undefined) delete process.env.NMG_DATA_DIR; + else process.env.NMG_DATA_DIR = previous; + } + }); +}); + +test("plan mode never emits feedback or an invented completion label", async (t) => { + const f = fixture(t); + const result = await reconcileOnce({ ...f.request, requestedMode: "plan" }, f.providers); + assert.equal(result.status, "planned"); + assert.equal(f.runtime.snapshot("owner"), null); +});