From 698bab7cc8a37d0d385f8b76b2017d59029c0dd9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:15:11 -0400 Subject: [PATCH 01/13] refactor: ban JSDoc in comment-free packages The comment-free packages (agent-core-v2, agent-gateway, transcript) no longer allow JSDoc on exported symbols. The checker rejects every JSDoc block, the existing blocks are removed, and the package guides state the rule. --- .agents/skills/agent-core-dev/orient.md | 4 +- .../agent-core-dev/service-authoring.md | 2 +- .agents/skills/agent-core-dev/verify.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- packages/agent-core-v2/AGENTS.md | 2 +- .../scripts/check-import-boundaries.mjs | 143 -------------- packages/agent-core-v2/scripts/debarrel.mjs | 53 +----- .../scripts/gen-contract-types.mjs | 40 +--- .../scripts/generate-webp-dec-wasm.mjs | 12 -- .../agent-core-v2/scripts/lib/jsonSchema.mts | 4 - .../agent-core-v2/src/_base/text/encoding.ts | 22 --- .../src/_base/text/line-endings.ts | 5 - .../src/agent/agentContext/agentSpace.ts | 8 - .../src/agent/contextMemory/contextMemory.ts | 7 - .../llmRequester/toolCallIdNormalizer.ts | 1 - .../src/agent/loop/configSection.ts | 1 - .../src/agent/loop/turnEvents.ts | 3 - .../agent-core-v2/src/agent/prompt/prompt.ts | 10 - .../src/agent/tools/os/read/read.ts | 5 - .../src/agent/turnBudget/turnBudget.ts | 8 - .../src/agent/turnRecovery/modelFallback.ts | 11 -- .../agent/turnRecovery/outputTokenRecovery.ts | 7 - .../agentProfileCatalog.ts | 14 -- .../agent-core-v2/src/app/capability/types.ts | 1 - .../agent-core-v2/src/app/config/config.ts | 17 -- .../src/app/config/configService.ts | 1 - .../agent-core-v2/src/app/file/fileService.ts | 6 - .../src/app/mcpConfig/configLoader.ts | 6 - .../src/app/mcpManagement/mcpManagement.ts | 45 ----- .../app/mcpManagement/mcpManagementService.ts | 1 - .../src/app/mcpRegistry/mcpRegistry.ts | 17 -- .../src/app/sessionIndex/sessionIndex.ts | 40 ---- .../src/app/sessionIndex/sessionIndexModel.ts | 11 -- .../app/sessionIndex/sessionIndexProjector.ts | 20 -- .../app/sessionIndex/sessionIndexService.ts | 57 ------ .../app/sessionIndex/sessionIndexSource.ts | 8 - .../src/app/telemetry/cloudTransport.ts | 6 - .../externalHooks/app/externalHooksRunner.ts | 1 - .../features/skill/session/skillCatalog.ts | 6 - .../src/features/tower/protocol/git.ts | 7 - .../src/features/tower/protocol/paths.ts | 7 - .../src/features/tower/protocol/store.ts | 50 ----- .../src/features/tower/protocol/types.ts | 37 ---- .../features/tower/tools/spawn/spawnTool.ts | 1 - .../src/features/tower/tools/support.ts | 10 - .../agent-core-v2/src/features/tower/tower.ts | 6 - .../src/features/tower/towerRateLimit.ts | 3 - .../features/tower/towerRateLimitService.ts | 2 - .../src/kosong/model/defaultModelPolicy.ts | 27 --- .../agent-core-v2/src/kosong/model/model.ts | 8 - .../provider/bases/openai/openai-responses.ts | 9 - .../agent-core-v2/src/mcpCore/configView.ts | 1 - .../src/mcpCore/connection-manager.ts | 16 -- .../src/mcpCore/oauth/callback-server.ts | 7 - .../src/mcpCore/oauth/provider.ts | 15 -- .../src/mcpCore/oauth/service.ts | 69 ------- .../src/persistence/interface/queryStore.ts | 21 -- .../src/runtime/standaloneRuntime.ts | 4 - .../session/agentLifecycle/agentLifecycle.ts | 18 -- .../sessionMetadata/sessionMetadata.ts | 7 - .../sessionTitle/agentTitlePromptSource.ts | 17 -- .../src/session/sessionTitle/sessionTitle.ts | 11 -- .../sessionTitle/sessionTitleService.ts | 6 - .../tokenCounting/sessionTokenCounting.ts | 8 - .../agent-core-v2/src/state/agentModel.ts | 13 -- .../src/tool/toolInputDisplay.ts | 5 - .../sessionLifecycle/sessionLifecycle.ts | 30 --- .../src/workspace/workspaceFs/fsWatch.ts | 1 - .../agent-gateway/src/instanceRegistry.ts | 19 -- packages/agent-gateway/src/lib/httpRange.ts | 5 - packages/agent-gateway/src/lib/promptMedia.ts | 38 ---- packages/agent-gateway/src/lib/requestLog.ts | 6 - packages/agent-gateway/src/middleware/auth.ts | 5 - .../src/middleware/defineRoute.ts | 34 ---- .../agent-gateway/src/middleware/hostnames.ts | 35 ---- .../agent-gateway/src/middleware/origin.ts | 30 --- .../agent-gateway/src/middleware/rateLimit.ts | 15 -- .../agent-gateway/src/middleware/schema.ts | 42 ---- .../src/middleware/securityHeaders.ts | 5 - .../agent-gateway/src/middleware/validate.ts | 15 -- .../agent-gateway/src/protocol/envelope.ts | 7 - .../agent-gateway/src/protocol/error-codes.ts | 11 -- .../src/protocol/rest-modelCatalog.ts | 39 ---- .../agent-gateway/src/protocol/rest-plugin.ts | 1 - .../src/protocol/rest-session.ts | 2 - .../agent-gateway/src/protocol/rest-skill.ts | 4 - .../src/protocol/rest-snapshot.ts | 5 - .../agent-gateway/src/protocol/ws-control.ts | 45 ----- packages/agent-gateway/src/requestLogging.ts | 14 -- .../src/routes/action-dispatch.ts | 16 -- .../agent-gateway/src/routes/action-suffix.ts | 10 - packages/agent-gateway/src/routes/fs.ts | 10 - packages/agent-gateway/src/routes/meta.ts | 20 -- packages/agent-gateway/src/routes/plugins.ts | 7 - .../agent-gateway/src/routes/questions.ts | 1 - .../src/routes/registerApiV1Routes.ts | 16 -- packages/agent-gateway/src/routes/sessions.ts | 10 - .../agent-gateway/src/routes/v2/sessions.ts | 6 - packages/agent-gateway/src/search/contract.ts | 85 --------- packages/agent-gateway/src/search/docs.ts | 39 ---- .../agent-gateway/src/search/indexCore.ts | 162 ---------------- packages/agent-gateway/src/search/match.ts | 68 ------- .../agent-gateway/src/search/searchService.ts | 116 ----------- packages/agent-gateway/src/search/snippet.ts | 9 - .../agent-gateway/src/search/wireExtract.ts | 40 ---- .../src/search/worker/dev-hooks.mjs | 9 - .../agent-gateway/src/search/worker/host.ts | 70 ------- .../src/search/worker/protocol.ts | 40 ---- .../src/search/worker/register-dev-hooks.mjs | 4 - .../src/search/worker/runtime.ts | 2 - .../src/security/bindClassify.ts | 8 - .../src/services/auth/authTokenService.ts | 14 -- .../src/services/auth/persistentToken.ts | 19 -- .../src/services/auth/tokenStore.ts | 11 -- .../src/services/guiStore/guiStore.ts | 6 - .../src/services/guiStore/guiStoreService.ts | 1 - .../src/services/legacyStatus/legacyStatus.ts | 20 -- .../src/services/messages/messageHistory.ts | 9 - .../services/messages/messageProjection.ts | 8 - .../agent-gateway/src/services/telemetry.ts | 1 - .../src/services/transcript/coreBinding.ts | 13 -- .../src/services/transcript/coreEventMap.ts | 152 --------------- .../services/transcript/transcriptService.ts | 134 ------------- packages/agent-gateway/src/start.ts | 88 --------- .../agent-gateway/src/transport/channel.ts | 1 - .../src/transport/channelRegistry.ts | 21 -- .../agent-gateway/src/transport/dispatcher.ts | 18 -- .../agent-gateway/src/transport/errors.ts | 13 -- .../src/transport/serviceDispatcherRoutes.ts | 9 - .../src/transport/ws/connectionRegistry.ts | 6 - .../src/transport/ws/v1/events.ts | 31 --- .../src/transport/ws/v1/fsWatchBridge.ts | 3 - .../transport/ws/v1/inFlightTurnTracker.ts | 1 - .../src/transport/ws/v1/protocol.ts | 1 - .../src/transport/ws/v1/registerWsV1.ts | 2 - .../ws/v1/sessionEventBroadcaster.ts | 180 ------------------ .../transport/ws/v1/sessionEventJournal.ts | 15 -- .../transport/ws/v1/subagentRosterTracker.ts | 1 - .../src/transport/ws/v1/wsConnectionV1.ts | 82 -------- .../agent-gateway/test/helpers/fixedAuth.ts | 5 - .../test/helpers/hostIdentity.ts | 5 - packages/transcript/src/contract/events.ts | 7 - packages/transcript/src/contract/mediaRef.ts | 25 --- packages/transcript/src/contract/schema.ts | 87 --------- .../transcript/src/granularity/filterOps.ts | 13 -- packages/transcript/src/granularity/grade.ts | 12 -- packages/transcript/src/model/attachment.ts | 9 - packages/transcript/src/model/frame.ts | 32 ---- packages/transcript/src/model/ids.ts | 1 - packages/transcript/src/model/interaction.ts | 8 - packages/transcript/src/model/item.ts | 19 -- packages/transcript/src/model/meta.ts | 20 -- packages/transcript/src/model/prompt.ts | 3 - packages/transcript/src/model/task.ts | 10 - packages/transcript/src/model/turn.ts | 25 --- packages/transcript/src/ops/apply.ts | 17 -- packages/transcript/src/ops/operation.ts | 39 ---- .../transcript/src/store/agentTranscript.ts | 7 - .../transcript/src/store/transcriptStore.ts | 4 - packages/transcript/src/view/registry.ts | 3 - scripts/check-no-comments.mjs | 118 +----------- 162 files changed, 22 insertions(+), 3459 deletions(-) diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 983f6637c..9eba13512 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -68,10 +68,10 @@ There is no domain-layer numbering — a domain may import any other domain, gui ## Comment convention -`packages/agent-core-v2/AGENTS.md` bans comments: no file headers, no section banners, no statement-level narration — the code is the source of truth. The only exception is JSDoc attached to exported symbols, which flows into the generated `.d.ts` and the consumers' IDE hover. Tooling directives (`eslint-disable`, `@ts-expect-error`, …) are banned too: fix the underlying lint/type problem instead, and put negative type-safety cases in compiler-asserted fixtures. DI scope is carried by registration: `LifecycleScope.App`, `LifecycleScope.Session`, or `LifecycleScope.Agent`. A `workspace*` filename marks workspace-domain ownership, not a DI scope (see service-authoring.md). +`packages/agent-core-v2/AGENTS.md` bans comments entirely: no file headers, no section banners, no statement-level narration, no JSDoc (not even on exported symbols) — the code is the source of truth. The only exception is a load-bearing lint-suppression directive (`oxlint-disable` / `eslint-disable`) for a deliberate pattern; other tooling directives (`@ts-expect-error`, …) are banned: fix the underlying lint/type problem instead, and put negative type-safety cases in compiler-asserted fixtures. DI scope is carried by registration: `LifecycleScope.App`, `LifecycleScope.Session`, or `LifecycleScope.Agent`. A `workspace*` filename marks workspace-domain ownership, not a DI scope (see service-authoring.md). ## Red lines (this stage) - Import via the `#/...` alias (mapped to `src/`); never reach into another domain's internals by relative path. - Short-lived may inject long-lived; never the reverse. -- No comments — not file headers, not beside statements; exported-symbol JSDoc is the only exception. +- No comments — not file headers, not beside statements, not JSDoc; a load-bearing lint-suppression directive is the only exception. diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index a215f0441..a340355c8 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -296,7 +296,7 @@ Importing the package therefore fires every `register*` side effect, exactly as ## Comments -- **No comments** (orient.md): no file headers, no statement-level narration; the only exception is JSDoc attached to exported symbols. +- **No comments** (orient.md): no file headers, no statement-level narration, no JSDoc; the only exception is a load-bearing lint-suppression directive. - **Methods and fields carry no comments by default.** Well-named identifiers and types say *what*; the code is the source of truth for *how*. - Write an inline comment only when the *why* is non-obvious (a hidden constraint, a subtle invariant, a workaround). One short line. - For unimplemented stubs, throw `NotImplementedError('feature')` rather than `throw new Error('TODO: …')` (errors.md). diff --git a/.agents/skills/agent-core-dev/verify.md b/.agents/skills/agent-core-dev/verify.md index b394d8eda..066469e5f 100644 --- a/.agents/skills/agent-core-dev/verify.md +++ b/.agents/skills/agent-core-dev/verify.md @@ -21,7 +21,7 @@ Walk the stages you touched and confirm: - **Design** — scope follows state identity; no `Map` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around. - **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior. - **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`. -- **Files** — no comments (exported-symbol JSDoc excepted); registration runs from the impl file's top level; the new domain is exported from `src/index.ts`. +- **Files** — no comments (no JSDoc either); registration runs from the impl file's top level; the new domain is exported from `src/index.ts`. Then re-read the [global red lines](SKILL.md#global-red-lines) once — they catch most cross-stage mistakes in a single scan. diff --git a/AGENTS.md b/AGENTS.md index dc36f9686..18d53d48a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ The web bundle: `apps/pythinker-code/dist-web` is the committed, prebuilt bundle ## Coding Rules - English-only codebase. Use ASCII/Latin fixtures (e.g. `café`) for unicode tests. -- `packages/agent-core-v2`, `packages/agent-gateway`, and `packages/transcript` are comment-free zones: no line/block comments; exceptions are JSDoc attached to exported symbols and load-bearing lint-suppression directives (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs`, which runs as part of `pnpm lint`. +- `packages/agent-core-v2`, `packages/agent-gateway`, and `packages/transcript` are comment-free zones: no line/block comments; no JSDoc either, not even on exported symbols; the only exception is a load-bearing lint-suppression directive (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs`, which runs as part of `pnpm lint`. - `packages/acp-adapter`: pin `@agentclientprotocol/sdk` `^0.23.0` (0.24+ broke session-model API). - `tsgo` (`@typescript/native-preview`) available via `npx tsgo -p --noEmit`; committed scripts use `tsc` — run both for type fixes. - Pass `undefined` directly for optional props — no conditional spread. diff --git a/CLAUDE.md b/CLAUDE.md index cbaca4000..ba443d5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## General Coding Rules -- `packages/agent-core-v2`, `packages/agent-gateway`, and `packages/transcript` are comment-free zones: no line/block comments; the exceptions are JSDoc attached to exported symbols and load-bearing lint-suppression directives (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs`, which runs as part of `pnpm lint`. +- `packages/agent-core-v2`, `packages/agent-gateway`, and `packages/transcript` are comment-free zones: no line/block comments; no JSDoc either, not even on exported symbols; the only exception is a load-bearing lint-suppression directive (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs`, which runs as part of `pnpm lint`. - For optional object properties, pass `undefined` directly instead of using conditional spread. - YES: `{ user }` - NO: `{ ...(user ? { user } : undefined) }` diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 951cdcd31..ed065aed8 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -29,7 +29,7 @@ The four contribution seams (token → fold): config sections — `ConfigSection ## Comment conventions -- **No comments.** The code is the source of truth; do not write file headers, section banners, or implementation narration. The one exception is JSDoc attached to exported symbols (it flows into the generated `.d.ts` and the consumers' IDE hover); keep it focused on the public contract. +- **No comments.** The code is the source of truth; do not write file headers, section banners, or implementation narration. No JSDoc either, not even on exported symbols. The one exception is a load-bearing lint-suppression directive (`oxlint-disable` / `eslint-disable`) for a deliberate pattern. - **Lint-suppression directives are the tooling exception.** `oxlint-disable` / `eslint-disable` comments are allowed where they suppress an active rule for a deliberate pattern (e.g. the Event2 class+payload-interface merging idiom). `@ts-expect-error`, `@ts-ignore`, and `ts-nocheck` stay banned — fix the underlying type problem instead; negative type-safety cases go into compiler-asserted fixtures. ## Telemetry diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs index 49b6d53c7..dd8ec759c 100644 --- a/packages/agent-core-v2/scripts/check-import-boundaries.mjs +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -1,40 +1,4 @@ #!/usr/bin/env node -/** - * Import-boundary checker for `agent-core-v2`. - * - * Enforces two rules over `packages/agent-core-v2/src/**` (and the v1-import - * ban over `test/**` too): - * - * 1. **No v1 imports** — v2 must never `import '@pymodel/agent-core'` - * (or any subpath). v2 ports logic; it never depends on v1. - * 2. **Kosong layering** — the `src/kosong/{contract,protocol,provider,model}` - * subtree has strict internal rules: - * - internal order: contract(L0) ← protocol(L1) ← provider/model(L2) - * ← catalog(L3); a lower layer never imports a higher one (so L1 - * protocol never sees L2 — trait contexts carry only `providerId`). - * - peer rule: `model` may import `provider`, never the reverse. - * - purity: `contract` imports no other domain (only `_base` helpers) - * and no external package at all (no SDKs, not even types); - * `protocol` imports only `_base` + `contract` and no wire SDK. - * All pure layers may additionally import the DI vocabulary modules - * in `KOSONG_ALLOWED_VOCABULARY` (`app/scopes`). - * - `provider/bases/` sub-boundary: base implementation files must not - * import the registries (`protocolBase`, `protocolAdapterRegistry`), - * `providerDefinition`, or any `*.contrib.ts` module. The - * registration side lives in `*.contrib.ts` and in each base - * directory's `index.ts` barrel (import = registration); both are - * exempt. - * Kosong directories that do not exist yet are skipped silently (later - * refactor phases add them). - * - * Intra-package relative imports, `#/`-alias imports, and the package's - * self-reference (`@pymodel/agent-core-v2/` → `src/`) are - * resolved against `src/`. Sibling packages (`@pymodel/*` other than v1) - * and third-party imports are out of scope (except for the kosong purity - * bans above). - * - * Run: `node scripts/check-import-boundaries.mjs`. Exits non-zero on violation. - */ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; @@ -48,24 +12,10 @@ const TEST_ROOT = join(PKG_ROOT, 'test'); const V1_PACKAGE = '@pymodel/agent-core'; const SELF_PACKAGE_PREFIX = '@pymodel/agent-core-v2/'; -/** - * Scope directories introduced by the `src/{scope}/{domain}` layout. A path's - * first segment is a scope tier, not a domain; the domain is the next segment. - */ const SCOPE_DIRS = new Set(['app', 'workspace', 'session', 'agent', 'persistence', 'os', 'kosong']); -/** - * Two-level scope directories: `persistence` and `os` use `{scope}/{tier}` - * (e.g. `persistence/interface`, `os/backends`) as the domain key; `kosong` - * uses `{scope}/{layer}` (e.g. `kosong/contract`) the same way. - */ const TWO_LEVEL_SCOPES = new Set(['persistence', 'os', 'kosong']); -/** - * Kosong-internal layer order: contract ← protocol ← provider/model. - * A lower layer never imports a higher one; `model` → `provider` - * is the only allowed peer edge. Keyed by the segment under `src/kosong/`. - */ const KOSONG_LAYER = new Map([ ['contract', 0], ['protocol', 1], @@ -73,40 +23,12 @@ const KOSONG_LAYER = new Map([ ['model', 2], ]); -/** - * Kosong is a pure provider/model abstraction layer: NO kosong subdomain may - * import another v2 domain outside kosong itself — only `_base` utilities - * are allowed, plus the DI vocabulary modules in - * `KOSONG_ALLOWED_VOCABULARY` (`app/scopes`: the `LifecycleScope` tier names - * every self-registering Service needs). (`protocol` additionally sees - * `kosong/contract`, handled by the internal-layer rule above.) Config - * persistence, OAuth tokens, events, - * and discovery orchestration all live in the upper `app/kosongConfig` - * wrapper — kosong must never reach up to them. - */ const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol', 'provider', 'model']); -/** - * Non-`_base` modules the pure kosong layers may still import, keyed by - * extensionless `src/`-relative path. `app/scopes` is DI vocabulary (the - * scope tier names + topology declaration), not app orchestration, so a - * kosong Service may read its registration tier from it. - */ const KOSONG_ALLOWED_VOCABULARY = new Set(['app/scopes']); -/** - * Wire SDK packages the pure kosong layers must never import — not even - * types. `contract` in fact imports no external package at all; this list - * covers the SDK ban for `protocol`. - */ const KOSONG_BANNED_SDK_PACKAGES = ['@anthropic-ai/sdk', '@google/genai', 'openai']; -/** - * Parse an absolute path under `src/kosong/` into its subdomain info. - * Returns `undefined` for paths outside `src/kosong/`. - * @param {string} absPath - * @returns {{ sub: string | undefined, inBases: boolean, isContrib: boolean, isIndex: boolean } | undefined} - */ function kosongInfoOf(absPath) { const rel = relative(SRC_ROOT, absPath); if (rel.startsWith('..') || rel === '') return undefined; @@ -115,7 +37,6 @@ function kosongInfoOf(absPath) { const sub = segments[1]; const last = segments[segments.length - 1] ?? ''; return { - // A file directly under `src/kosong/` has no subdomain. sub: sub === undefined || sub.endsWith('.ts') ? undefined : sub, inBases: sub === 'provider' && segments[2] === 'bases', isContrib: last.endsWith('.contrib.ts'), @@ -123,16 +44,6 @@ function kosongInfoOf(absPath) { }; } -/** - * Whether an import target is off-limits to base implementation files under - * `kosong/provider/bases/` (everything except `*.contrib.ts` and the - * registration `index.ts` barrels): the base registry - * (`kosong/protocol/protocolBase`), the adapter registry - * (`kosong/provider/protocolAdapterRegistry`), the provider-definition - * registry (`kosong/provider/providerDefinition`), or any contrib - * side-effect module. Matches extensionless specifiers too. - * @param {string} targetAbs - */ function isKosongBasesBannedTarget(targetAbs) { const rel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); const stripped = rel.endsWith('.ts') ? rel.slice(0, -'.ts'.length) : rel; @@ -144,21 +55,13 @@ function isKosongBasesBannedTarget(targetAbs) { ); } -/** - * Resolve a `src/`-relative path to its domain, skipping the scope tier when - * present. Returns `undefined` for top-level root files (e.g. the package - * barrel `index.ts`, or the `errors`/`hooks` facades). - * @param {string} rel - */ function domainFromRel(rel) { const segments = rel.split(/[\\/]/); if (TWO_LEVEL_SCOPES.has(segments[0])) { - // `src/{persistence|os}/{interface|backends}/…` return segments[1] ? `${segments[0]}/${segments[1]}` : segments[0]; } if (SCOPE_DIRS.has(segments[0])) { if (segments.length === 2 && segments[1]?.endsWith('.ts')) return segments[0]; - // `src/{scope}/{domain}/…` if (segments[0] === 'agent' && segments[1] === 'task') return 'agentTask'; if (segments[0] === 'agent' && segments[1] === 'plugin') return 'agentPlugin'; return segments[1]; @@ -166,30 +69,16 @@ function domainFromRel(rel) { return segments[0]; } -/** - * Determine the v2 domain for an *import target* absolute path. A target may - * resolve straight to a domain directory — e.g. the bare domain import - * `#/turn` resolves to `src/agent/turn`, whose domain is `turn`. - * @param {string} targetAbs - */ function targetDomainOf(targetAbs) { const rel = relative(SRC_ROOT, targetAbs); if (rel.startsWith('..') || rel === '') return undefined; return domainFromRel(rel); } -/** - * Resolve an import specifier to an absolute v2 `src/` path, or `undefined` - * when the specifier is not an intra-v2 import. - * @param {string} specifier - * @param {string} fromFile absolute path of the importing file - */ function resolveIntraV2(specifier, fromFile) { if (specifier.startsWith('#/')) { return join(SRC_ROOT, specifier.slice(2)); } - // The package's legal self-reference: `@pymodel/agent-core-v2/x` maps - // to `src/x` via the `./*` export. if (specifier.startsWith(SELF_PACKAGE_PREFIX)) { return join(SRC_ROOT, specifier.slice(SELF_PACKAGE_PREFIX.length)); } @@ -199,22 +88,9 @@ function resolveIntraV2(specifier, fromFile) { return undefined; } -// Matches: import ... from 'x' | export ... from 'x' | import('x') | require('x') const IMPORT_RE = /(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]|(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; -/** - * @typedef {{ file: string, line: number, message: string }} Violation - */ - -/** - * Check source text for boundary violations. `absFile` is used only to - * resolve relative specifiers and determine the source location; the file - * need not exist on disk (handy for tests). - * @param {string} source - * @param {string} absFile - * @returns {Violation[]} - */ export function checkSource(source, absFile) { const violations = []; const inSrc = !relative(SRC_ROOT, absFile).startsWith('..'); @@ -226,7 +102,6 @@ export function checkSource(source, absFile) { if (!specifier) continue; const line = source.slice(0, match.index).split('\n').length; - // Rule 1: v2 must not import v1. if (specifier === V1_PACKAGE || specifier.startsWith(`${V1_PACKAGE}/`)) { violations.push({ file: absFile, @@ -236,15 +111,11 @@ export function checkSource(source, absFile) { continue; } - // Rule 2: kosong subtree (production code only). if (!inSrc) continue; const targetAbs = resolveIntraV2(specifier, absFile); const sourceKosong = kosongInfoOf(absFile); if (sourceKosong === undefined) continue; - // Rule 2a: kosong purity bans on external packages. The L0 contract - // imports no external package at all (no SDKs, not even types); the L1 - // protocol layer is SDK-free but may use general-purpose packages. if (targetAbs === undefined) { if (sourceKosong.sub === 'contract') { violations.push({ @@ -267,9 +138,6 @@ export function checkSource(source, absFile) { continue; } - // Rule 2b: kosong-internal layering. Runs even for same-domain imports - // because the provider/bases sub-boundary also bans same-domain targets - // (registries and contrib modules live beside the bases). const targetKosong = kosongInfoOf(targetAbs); if (targetKosong !== undefined) { const sourceKosongLayer = KOSONG_LAYER.get(sourceKosong.sub); @@ -304,11 +172,6 @@ export function checkSource(source, absFile) { continue; } - // Rule 2c: outside the kosong subtree, kosong code may only depend on - // `_base` utilities plus the DI vocabulary in KOSONG_ALLOWED_VOCABULARY - // (`protocol` additionally sees `kosong/contract`, - // handled by Rule 2b above). This is what keeps kosong a pure - // abstraction layer with no upward dependencies. if (KOSONG_BASE_ONLY_SUBDOMAINS.has(sourceKosong.sub)) { const targetDomain = targetDomainOf(targetAbs); const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); @@ -326,17 +189,11 @@ export function checkSource(source, absFile) { return violations; } -/** - * Check a single source file for boundary violations. - * @param {string} absFile - * @returns {Violation[]} - */ export function checkFile(absFile) { return checkSource(readFileSync(absFile, 'utf8'), absFile); } function walk(dir) { - /** @type {string[]} */ const out = []; for (const entry of readdirSync(dir)) { if (entry === 'node_modules' || entry === 'dist') continue; diff --git a/packages/agent-core-v2/scripts/debarrel.mjs b/packages/agent-core-v2/scripts/debarrel.mjs index a95bc56b7..84715e0fd 100644 --- a/packages/agent-core-v2/scripts/debarrel.mjs +++ b/packages/agent-core-v2/scripts/debarrel.mjs @@ -1,20 +1,4 @@ #!/usr/bin/env node -/** - * debarrel.mjs — agent-core-v2 barrel removal tool (ts-morph). - * - * Rewrites `#/` barrel imports/exports to precise leaf-file specifiers and - * regenerates the package entry `src/index.ts` so it loads every domain leaf - * (triggering all top-level `register*` side effects) without domain barrels. - * - * Modes: - * (default) rewrite all consumer files (src + test) EXCEPT src/index.ts - * --only= limit consumer rewriting to one barrel, e.g. app/event - * --entry regenerate src/index.ts only (no consumer rewriting) - * --delete-barrels delete every domain barrel (per-domain src index.ts except entry) - * --list-registers print the top-level register* files (coverage set) - * --verify-coverage exit non-zero if any register file is unreachable from entry - * --dry-run report planned edits without writing - */ import { Project } from 'ts-morph'; import path from 'node:path'; import fs from 'node:fs'; @@ -50,8 +34,6 @@ const barrelOfDecl = (decl) => { return sf && isBarrelFile(sf) ? sf : null; }; -// Resolve a name exported by `barrel` to the leaf file that declares it and the -// name that leaf uses to export it (handles `export { A as B }` at barrel level). function resolveName(barrel, name) { const decls = barrel.getExportedDeclarations().get(name); if (!decls || decls.length === 0) return null; @@ -69,8 +51,6 @@ function resolveName(barrel, name) { return { leafFile: leaf.getFilePath(), leafName }; } -// Ordered re-export clauses of a barrel (recursively inlines nested barrels), -// preserving source order so `export *` collision resolution is unchanged. function expandBarrelClauses(barrel) { const clauses = []; for (const ed of barrel.getExportDeclarations()) { @@ -121,13 +101,9 @@ function allLeavesUnderDir(dirAbs) { return out.sort((a, b) => a.localeCompare(b)); } -// --------------------------------------------------------------------------- -// Consumer rewriting (imports + named exports + export *) for a single file. -// --------------------------------------------------------------------------- function rewriteConsumerFile(sf, onlyBarrelPath) { const report = { imports: 0, exports: 0, manuals: [], sideEffects: 0 }; - // Imports. for (const decl of sf.getImportDeclarations()) { const barrel = barrelOfDecl(decl); if (!barrel) continue; @@ -140,7 +116,6 @@ function rewriteConsumerFile(sf, onlyBarrelPath) { const hasDefault = !!decl.getDefaultImport(); const named = decl.getNamedImports(); if (!hasDefault && named.length === 0) { - // side-effect: import '#/B' -> load each leaf of B. const leaves = [...new Set(expandBarrelClauses(barrel).map((c) => c.file))]; const idx = sf.getImportDeclarations().indexOf(decl); sf.insertImportDeclarations( @@ -154,7 +129,7 @@ function rewriteConsumerFile(sf, onlyBarrelPath) { } const declType = decl.isTypeOnly(); - const groups = new Map(); // leafFile -> [{name, alias, isTypeOnly}] + const groups = new Map(); const add = (leaf, spec) => { if (!groups.has(leaf)) groups.set(leaf, []); groups.get(leaf).push(spec); @@ -166,7 +141,7 @@ function rewriteConsumerFile(sf, onlyBarrelPath) { else add(r.leafFile, { default: decl.getDefaultImport().getText() }); } for (const s of named) { - const lookup = s.getName(); // module-exported name + const lookup = s.getName(); const local = s.getAliasNode()?.getText() || s.getName(); const r = resolveName(barrel, lookup); if (!r) { @@ -186,7 +161,6 @@ function rewriteConsumerFile(sf, onlyBarrelPath) { report.imports++; } - // Exports. for (const decl of sf.getExportDeclarations()) { const barrel = barrelOfDecl(decl); if (!barrel) continue; @@ -203,11 +177,10 @@ function rewriteConsumerFile(sf, onlyBarrelPath) { report.manuals.push({ sf: sf.getFilePath(), text: decl.getText(), why: 'namespace export' }); continue; } - // named re-export const declType = decl.isTypeOnly(); const groups = new Map(); for (const s of decl.getNamedExports()) { - const lookup = s.getName(); // name the consumer re-exports (= barrel's exported name) + const lookup = s.getName(); const exportedAs = s.getAliasNode()?.getText() || s.getName(); const r = resolveName(barrel, lookup); if (!r) { @@ -273,17 +246,12 @@ function exportClauseToText(c) { return renderNamedExport(relSpec(c.file), c.specs, c.isTypeOnly); } -// --------------------------------------------------------------------------- -// Entry (src/index.ts) regeneration. -// --------------------------------------------------------------------------- function regenerateEntry() { const entrySf = project.getSourceFileOrThrow(ENTRY); const original = entrySf.getFullText(); const headerMatch = original.match(/^\s*\/\*\*[\s\S]*?\*\//); const header = headerMatch ? headerMatch[0] : '/** agent-core-v2 public surface. */'; - // First pass: classify each referenced barrel and how it is referenced. - /** @type {Array<{decl: any, barrel: any, mode: 'star'|'named'|'side'}>} */ const refs = []; for (const decl of [...entrySf.getExportDeclarations(), ...entrySf.getImportDeclarations()]) { const barrel = barrelOfDecl(decl); @@ -309,7 +277,6 @@ function regenerateEntry() { const starLeaves = new Set(clauses.filter((c) => c.kind === 'star').map((c) => c.file)); if (mode === 'star') { - // Public: replay the barrel's clauses in order against precise leaves. for (const c of clauses) publicLines.push(exportClauseToText(c)); } else if (mode === 'named') { const declType = decl.isTypeOnly(); @@ -333,11 +300,9 @@ function regenerateEntry() { publicLines.push(renderNamedExport(relSpec(leaf), specs, allType)); } } - // Loading: any leaf of this domain not already pulled in by an `export *` - // line must be imported for its side effects (registers). for (const leaf of allLeaves) { const key = leaf; - if (starLeaves.has(leaf)) continue; // loaded by export * + if (starLeaves.has(leaf)) continue; if (processed.has(key)) continue; processed.add(key); loadingLines.push(`import '${relSpec(leaf)}';`); @@ -360,9 +325,6 @@ function regenerateEntry() { return { publicLines: publicLines.length, loadingLines: loadingLines.length }; } -// --------------------------------------------------------------------------- -// Register-file enumeration + coverage verification. -// --------------------------------------------------------------------------- const REGISTER_NAMES = new Set([ 'registerScopedService', 'registerAgentToolService', @@ -419,7 +381,7 @@ function reachedFromEntry() { if (!isUnderSrc(f)) return; const edges = [...sf.getImportDeclarations(), ...sf.getExportDeclarations()]; for (const d of edges) { - if (d.isTypeOnly && d.isTypeOnly()) continue; // type-only edges don't execute + if (d.isTypeOnly && d.isTypeOnly()) continue; const t = resolvedFile(d); if (t && isUnderSrc(t.getFilePath())) visit(t); } @@ -452,9 +414,6 @@ function deleteBarrels() { return n; } -// --------------------------------------------------------------------------- -// Main dispatch. -// --------------------------------------------------------------------------- function main() { if (LIST_REGS) { for (const f of findRegisterFiles()) console.log(path.relative(PKG, f)); @@ -483,7 +442,7 @@ function main() { for (const sf of project.getSourceFiles()) { const f = sf.getFilePath(); if (!isUnderSrc(f) && !f.startsWith(path.join(PKG, 'test') + path.sep)) continue; - if (f === ENTRY) continue; // entry handled by --entry + if (f === ENTRY) continue; const before = sf.getFullText(); const r = rewriteConsumerFile(sf, onlyBarrelPath); if (sf.getFullText() !== before) { diff --git a/packages/agent-core-v2/scripts/gen-contract-types.mjs b/packages/agent-core-v2/scripts/gen-contract-types.mjs index 0ba21952b..a5896bbe2 100644 --- a/packages/agent-core-v2/scripts/gen-contract-types.mjs +++ b/packages/agent-core-v2/scripts/gen-contract-types.mjs @@ -1,24 +1,3 @@ -/** - * Generates a black-box "contract" declaration tree for agent-core-v2. - * - * The output mirrors `src/` but with every registered service IMPLEMENTATION - * class removed, leaving only the contract surface: interfaces, types, models, - * error domains, factory functions, the `ServiceIdentifier` accessors, and the - * DI primitives. External contract consumers type-check against this tree - * so tests cannot import an impl class, while at runtime the real linked - * package still binds the real implementations. - * - * Pipeline: - * 1. `tsc --emitDeclarationOnly` over `src/` into a temp dir. - * 2. Detect impl files = source files containing a top-level - * `registerScopedService(...)` call; the 3rd argument is the impl class. - * 3. In each impl file's emitted `.d.ts`, drop the registered class - * declaration(s) and keep everything else, then drop re-export - * specifiers elsewhere in the tree that name a dropped class - * (deprecated alias modules) — they would otherwise dangle. - * 4. Copy the scrubbed tree to the output directory. - */ - import { execFileSync } from 'node:child_process'; import { cpSync, @@ -36,7 +15,7 @@ import { createRequire } from 'node:module'; import { Project, SyntaxKind } from 'ts-morph'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PKG = join(__dirname, '..'); // packages/agent-core-v2 +const PKG = join(__dirname, '..'); const SRC = join(PKG, 'src'); const TMP = join(PKG, '.contract-types-tmp'); const TSCONFIG = join(PKG, 'tsconfig.contract.json'); @@ -61,13 +40,9 @@ function walk(dir, out) { } } -// 1. Emit declarations for the whole src tree. rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); log(`emitting declarations via tsc -> ${relative(PKG, TMP)}`); -// tsc exits non-zero on the repo's pre-existing type errors (WIP port), but -// still emits `.d.ts` for every file when `noEmitOnError` is off. We only need -// the declarations, so tolerate a non-zero exit and continue. try { execFileSync(process.execPath, [tscBin, '-p', TSCONFIG, '--outDir', TMP], { cwd: PKG, @@ -78,12 +53,10 @@ try { log(`tsc exited ${String(code)} (non-fatal; declarations are still emitted)`); } -// 2. Detect impl files + registered class names (AST only). log('scanning for registerScopedService(...) bindings'); const project = new Project(); project.addSourceFilesAtPaths(join(SRC, '**', '*.ts')); -/** @type {Map>} dtsPath -> class names to drop */ const dropByDts = new Map(); const implFiles = []; @@ -99,7 +72,6 @@ for (const sf of project.getSourceFiles()) { const args = call.getArguments(); if (args.length < 3) continue; const text = args[2].getText().trim(); - // Only treat a bare identifier as a class name; otherwise signal "drop all". names.add(/^[A-Za-z_$][\w$]*$/.test(text) ? text : '*'); } @@ -112,7 +84,6 @@ for (const sf of project.getSourceFiles()) { log(`found ${implFiles.length} impl files`); -// 3. Scrub registered classes from each impl .d.ts. let scrubbedFiles = 0; let scrubbedClasses = 0; for (const [dtsPath, names] of dropByDts) { @@ -136,11 +107,6 @@ for (const [dtsPath, names] of dropByDts) { } log(`scrubbed ${scrubbedClasses} impl class(es) across ${scrubbedFiles} file(s)`); -// 3b. Scrub re-exports of scrubbed classes. A deprecated alias module (e.g. -// `export { Impl as OldName } from './implService'`) would otherwise keep -// naming a class its declaring file no longer exports — a dangling reference -// for consumers and an impl-name leak. `export *` needs nothing: it only -// re-exports what survives. function resolveReexportTarget(dtsPath, spec) { const clean = spec.endsWith('.js') ? spec.slice(0, -'.js'.length) : spec; if (clean.startsWith('.')) return join(dirname(dtsPath), `${clean}.d.ts`); @@ -178,19 +144,15 @@ for (const dtsPath of emittedDts) { } log(`scrubbed ${scrubbedReexports} re-export(s) of impl classes from alias modules`); -// 4. Copy the scrubbed tree to the output directory. rmSync(OUT, { recursive: true, force: true }); mkdirSync(dirname(OUT), { recursive: true }); cpSync(TMP, OUT, { recursive: true }); -// Sanity summary: report emitted files + a quick leak check (any impl class -// name still declared in its own file). const emitted = []; walk(OUT, emitted); const dtsCount = emitted.filter((f) => f.endsWith('.d.ts')).length; log(`wrote ${dtsCount} declaration file(s) -> ${OUT}`); -// Verify no registered class name survives in the file that registered it. const leaks = []; for (const [dtsPath, names] of dropByDts) { const outPath = join(OUT, relative(TMP, dtsPath)); diff --git a/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs b/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs index 77ac100a2..04ac72d65 100644 --- a/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs +++ b/packages/agent-core-v2/scripts/generate-webp-dec-wasm.mjs @@ -1,15 +1,3 @@ -/** - * Regenerate `src/agent/media/webp-dec-wasm.ts` from the installed - * `@jsquash/webp` package. - * - * The WebP decoder wasm is committed as a base64 string module because the - * published CLI bundles every dependency into a single file with no runtime - * node_modules — a file-path lookup for the .wasm would break there, while a - * string constant survives every packaging (vitest on sources, tsdown - * bundling, nix builds) unchanged. Run this after bumping @jsquash/webp: - * - * node scripts/generate-webp-dec-wasm.mjs - */ import { createRequire } from 'node:module'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; diff --git a/packages/agent-core-v2/scripts/lib/jsonSchema.mts b/packages/agent-core-v2/scripts/lib/jsonSchema.mts index dafca8175..e02f182a0 100644 --- a/packages/agent-core-v2/scripts/lib/jsonSchema.mts +++ b/packages/agent-core-v2/scripts/lib/jsonSchema.mts @@ -8,7 +8,6 @@ export function truncate(text: string, max = 100): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } -/** Property access shape of a JSON Schema node (avoids index-signature access). */ export interface JsonSchema { readonly $ref?: unknown; readonly $defs?: unknown; @@ -28,7 +27,6 @@ export function asJsonSchema(value: unknown): JsonSchema | undefined { return isRecord(value) ? (value as JsonSchema) : undefined; } -/** Resolve a `#/$defs/` reference against the root schema. */ export function resolveRef(schema: unknown, root: JsonSchema): unknown { const s = asJsonSchema(schema); if (typeof s?.$ref === 'string' && s.$ref.startsWith('#/$defs/')) { @@ -41,7 +39,6 @@ export function resolveRef(schema: unknown, root: JsonSchema): unknown { return schema; } -/** One-line type description of a JSON Schema node (`"a" | "b"`, `Foo[]`, …). */ export function describeType( schema: unknown, quoteString: (raw: string) => string = (s) => JSON.stringify(s), @@ -78,7 +75,6 @@ export function describeType( return 'any'; } -/** Project a zod schema to JSON Schema; `undefined` when it uses transforms. */ export function toJsonSchema(schema: unknown): JsonSchema | undefined { try { return z.toJSONSchema(schema as never) as JsonSchema; diff --git a/packages/agent-core-v2/src/_base/text/encoding.ts b/packages/agent-core-v2/src/_base/text/encoding.ts index a0216dc67..154e5bf17 100644 --- a/packages/agent-core-v2/src/_base/text/encoding.ts +++ b/packages/agent-core-v2/src/_base/text/encoding.ts @@ -8,19 +8,10 @@ export interface TextClassification { export const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; export interface TextEncodingDetection { - /** - * Detected encoding. `'utf-8'` when no signal points elsewhere (also the - * placeholder when `seemsBinary` is true). - */ readonly encoding: UtfTextEncoding; - /** - * True when zero bytes appear but fit neither UTF-16 pattern — the sample - * should be treated as binary, not text. - */ readonly seemsBinary: boolean; } -/** Number of leading bytes inspected for the zero-byte heuristic. */ export const ENCODING_DETECTION_SAMPLE_BYTES = 512; const MIN_ZERO_BYTES_FOR_UTF16 = 2; @@ -112,24 +103,11 @@ export function classifyTextSample(sample: Uint8Array): TextClassification { return { isBinary: false, encoding: 'utf-8' }; } -/** - * Detect the encoding of a text file from its leading bytes. - * - * Known limitation: a BOM-less - * UTF-16 file whose content carries no zero bytes at all (e.g. purely CJK - * text) is reported as `'utf-8'`; strict UTF-8 decoding of it will then fail - * or produce garbage. Notepad and most editors write a BOM, so this is rare - * in practice. - */ export function detectTextEncoding(sample: Uint8Array): TextEncodingDetection { const classification = classifyTextSample(sample); return { encoding: classification.encoding, seemsBinary: classification.isBinary }; } -/** - * Decode bytes in a detected UTF encoding to a JS string. Malformed - * sequences are replaced (non-fatal) and a leading BOM is stripped. - */ export function decodeUtfText(bytes: Uint8Array, encoding: UtfTextEncoding): string { return new TextDecoder(encoding, { fatal: false }).decode(bytes); } diff --git a/packages/agent-core-v2/src/_base/text/line-endings.ts b/packages/agent-core-v2/src/_base/text/line-endings.ts index 62508eccc..09725d377 100644 --- a/packages/agent-core-v2/src/_base/text/line-endings.ts +++ b/packages/agent-core-v2/src/_base/text/line-endings.ts @@ -50,11 +50,6 @@ export function makeCarriageReturnsVisible(text: string): string { return text.replaceAll('\r', '\\r'); } -/** - * Split text into lines, keeping each line's trailing `\n` (the final line - * may lack one). Same semantics as Python's `str.splitlines(keepends=True)` - * restricted to `\n` boundaries. - */ export function splitLinesKeepingTerminator(text: string): string[] { if (text.length === 0) return []; const lines: string[] = []; diff --git a/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts index 09ec5e9c2..db963e274 100644 --- a/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts +++ b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts @@ -12,14 +12,6 @@ import type { AgentContext } from './agentContext'; export type AgentModelInstanceOf = D extends AgentModelDefinition ? M : never; -/** - * Per-agent store of materialized domain Model instances, minted by the - * agent lifecycle together with the `AgentContext`. `use` runs `run` against - * the definition's instance under a lease: synchronous when `run` is - * synchronous, otherwise the lease extends until the returned promise - * settles. Stale (disposed) spaces reject every call; contexts not issued by - * the lifecycle carry no space at all. - */ export interface AgentSpace { use, R>( definition: D, diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 46950e989..1e7162b96 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -10,14 +10,7 @@ export interface ContextCompactionInput { readonly compactedCount: number; readonly tokensBefore: number; readonly tokensAfter?: number; - /** Measured output tokens of the compaction LLM exchange (the REAL summary - * size); preferred over the summary-text estimate in the `tokensAfter` - * fallback when present. */ readonly summaryOutputTokens?: number; - /** Estimated fixed request overhead (system prompt + non-deferred tool - * schemas) that every post-compaction exchange still carries. Counted into - * the `tokensAfter` fallback so the result stays on the same full-request - * basis as the measured exchange anchors. */ readonly requestOverheadTokens?: number; readonly keptUserMessageCount?: number; readonly keptHeadUserMessageCount?: number; diff --git a/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts b/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts index 0a21267c5..e8eb0e3ff 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts @@ -22,7 +22,6 @@ export class ToolCallIdResponseNormalizer { private readonly assignedByIndex = new Map(); private readonly occurrencesByRawId = new Map(); private readonly claimed: string[] = []; - /** Every rewrite applied to this response, oldest first (for provenance logging). */ readonly remapped: { raw: string; assigned: string }[] = []; constructor(private readonly seen: Set) {} diff --git a/packages/agent-core-v2/src/agent/loop/configSection.ts b/packages/agent-core-v2/src/agent/loop/configSection.ts index df91b8b38..7b6e5256a 100644 --- a/packages/agent-core-v2/src/agent/loop/configSection.ts +++ b/packages/agent-core-v2/src/agent/loop/configSection.ts @@ -9,7 +9,6 @@ export const LOOP_CONTROL_SECTION = 'loopControl'; export const LOOP_MAX_STEPS_PER_TURN_ENV = 'PYTHINKER_LOOP_MAX_STEPS_PER_TURN'; export const LOOP_MAX_ATTEMPTS_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP'; export const LOOP_TURN_BUDGET_TOKENS_ENV = 'PYTHINKER_LOOP_TURN_BUDGET_TOKENS'; -/** Deprecated former name of {@link LOOP_MAX_ATTEMPTS_PER_STEP_ENV}. */ export const LOOP_MAX_RETRIES_PER_STEP_ENV = 'PYTHINKER_LOOP_MAX_RETRIES_PER_STEP'; export const LoopControlSchema = z.object({ diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index 3612f9c0e..4994ea4a0 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -43,9 +43,6 @@ export function turnPromptText( return text.length > 0 ? text : undefined; } -/** Media parts become the turn's transcript attachments only when they point - * at a session upload — the id must match the part's daemon file URL (a - * provider-issued id on a remote URL is not a session-media file id). */ export function turnPromptAttachments( input: readonly ContentPart[], ): TurnStartedPayload['promptAttachments'] { diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index b82d09642..4eb7136d2 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -51,17 +51,7 @@ export interface PromptQueueSnapshot { export interface PromptPayload { readonly input: readonly ContentPart[]; - /** - * Client-managed session tool denylist (full-replace semantics), applied - * before the prompt is enqueued. Omit to keep the current value; `[]` - * clears the client portion. - */ readonly disabledTools?: readonly string[]; - /** - * Client-chosen prompt record id, echoed on the consuming turn's - * `turn.started` (`promptId`). A duplicate id rejects the submission before - * any session state is touched. - */ readonly promptId?: string; } diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.ts b/packages/agent-core-v2/src/agent/tools/os/read/read.ts index 8525c7c37..881b20182 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.ts @@ -7,11 +7,6 @@ export const MAX_LINES: number = 1000; export const MAX_LINE_LENGTH: number = 2000; export const MAX_BYTES: number = 100 * 1024; -/** - * Largest file the Read tool transcodes from UTF-16 in memory. Unlike the - * streaming UTF-8 path, transcoding needs the whole file decoded at once; - * 10 MiB mirrors agent-gateway's `FS_READ_MAX_BYTES`. - */ export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024; const PositiveLineOffsetSchema = z.number().int().min(1); diff --git a/packages/agent-core-v2/src/agent/turnBudget/turnBudget.ts b/packages/agent-core-v2/src/agent/turnBudget/turnBudget.ts index 77065d010..363331d8c 100644 --- a/packages/agent-core-v2/src/agent/turnBudget/turnBudget.ts +++ b/packages/agent-core-v2/src/agent/turnBudget/turnBudget.ts @@ -1,9 +1,5 @@ import { createDecorator } from '#/_base/di/instantiation'; -/** - * Continues a turn toward a configured output-token target by injecting - * continuation nudges while progress holds, stopping on diminishing returns. - */ export interface IAgentTurnBudgetService { readonly _serviceBrand: undefined; } @@ -11,14 +7,10 @@ export interface IAgentTurnBudgetService { export const IAgentTurnBudgetService = createDecorator('agentTurnBudgetService'); -/** Fraction of the configured token target a turn must reach before stopping naturally. */ export const TURN_BUDGET_COMPLETION_THRESHOLD = 0.9; -/** Per-step output-token delta below which a step counts as low-progress. */ export const TURN_BUDGET_DIMINISHING_MIN_DELTA_TOKENS = 500; -/** Continuations after which consecutive low-progress deltas stop the turn. */ export const TURN_BUDGET_MAX_DIMINISHING_CONTINUATIONS = 3; -/** Builds the meta nudge injected before each budget continuation. */ export function turnBudgetNudgeText(pct: number, used: number, budget: number): string { return `Stopped at ${pct}% of token target (${used} / ${budget}). Keep working - do not summarize.`; } diff --git a/packages/agent-core-v2/src/agent/turnRecovery/modelFallback.ts b/packages/agent-core-v2/src/agent/turnRecovery/modelFallback.ts index 20b7d627b..64538cf18 100644 --- a/packages/agent-core-v2/src/agent/turnRecovery/modelFallback.ts +++ b/packages/agent-core-v2/src/agent/turnRecovery/modelFallback.ts @@ -3,20 +3,9 @@ import { createDecorator } from '#/_base/di/instantiation'; import { Event2 } from '#/app/event/event2'; import type { LoopErrorContext } from '#/agent/loop/loop'; -/** - * Switches the agent to the configured fallback model when step retries are - * exhausted on persistent retryable provider errors, so the retrying layer can - * resend the failed step on the fallback. - */ export interface IAgentModelFallbackService { readonly _serviceBrand: undefined; - /** - * Switches the agent profile to `loopControl.fallback_model` when allowed - * (flag on, model configured and different from the current one, not yet - * used this turn). Returns true when the switch happened and the caller - * should retry the failed driver. - */ tryFallbackSwitch(context: LoopErrorContext): Promise; } diff --git a/packages/agent-core-v2/src/agent/turnRecovery/outputTokenRecovery.ts b/packages/agent-core-v2/src/agent/turnRecovery/outputTokenRecovery.ts index 837aa559a..87ef77e17 100644 --- a/packages/agent-core-v2/src/agent/turnRecovery/outputTokenRecovery.ts +++ b/packages/agent-core-v2/src/agent/turnRecovery/outputTokenRecovery.ts @@ -1,10 +1,5 @@ import { createDecorator } from '#/_base/di/instantiation'; -/** - * Recovers turns whose model response ended truncated at the output token - * limit without tool calls, by injecting a resume nudge and continuing the - * turn. - */ export interface IAgentOutputTokenRecoveryService { readonly _serviceBrand: undefined; } @@ -12,10 +7,8 @@ export interface IAgentOutputTokenRecoveryService { export const IAgentOutputTokenRecoveryService = createDecorator('agentOutputTokenRecoveryService'); -/** Maximum resume-nudge continuations injected per turn for truncated output. */ export const MAX_OUTPUT_TOKEN_RECOVERY_ATTEMPTS = 3; -/** Meta user message appended before each output-token recovery continuation. */ export const OUTPUT_TOKEN_RECOVERY_NUDGE = 'Output token limit hit. Resume directly - no apology, no recap of what you were doing. ' + 'Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.'; diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index ebedef4ae..1e13f5f12 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -59,20 +59,6 @@ export interface AgentProfile { readonly summaryPolicy?: AgentProfileSummaryPolicy; } -/** - * The profile shape accepted at registration ({@link registerAgentProfile}, - * file-based profile factories): authors provide at least one render entry — - * the structured `renderSystemPrompt`, the legacy text-only `systemPrompt`, - * or both (the structured renderer is then authoritative). The union - * statically requires at least one entry; {@link normalizeAgentProfile} still - * throws on inputs that escaped the type check (plain JS, casts). - * {@link normalizeAgentProfile} derives the other method, so a registered - * {@link AgentProfile} always carries both and its `systemPrompt` text always - * comes from the same render as its disclosure metadata. A text-only input - * renders with no disclosed environment facts. Callbacks are bound to the - * input object at runtime, so method-style definitions relying on `this` - * keep working. - */ export type AgentProfileInput = Omit & ( | { diff --git a/packages/agent-core-v2/src/app/capability/types.ts b/packages/agent-core-v2/src/app/capability/types.ts index 71e121016..b7fcc7258 100644 --- a/packages/agent-core-v2/src/app/capability/types.ts +++ b/packages/agent-core-v2/src/app/capability/types.ts @@ -26,7 +26,6 @@ export interface CapabilityDetectResult { export interface CapabilityStatus { readonly id: CapabilityId; - /** Plugin identifier used to provide this capability's agent wiring. */ readonly pluginId?: string; readonly displayName: string; readonly description: string; diff --git a/packages/agent-core-v2/src/app/config/config.ts b/packages/agent-core-v2/src/app/config/config.ts index d1a9dc33b..9003c1d36 100644 --- a/packages/agent-core-v2/src/app/config/config.ts +++ b/packages/agent-core-v2/src/app/config/config.ts @@ -13,26 +13,14 @@ export type EnvBinding = | string | { readonly env: string; - /** - * Deprecated former name of `env`. Still honored (with a deprecation - * warning) when `env` itself is absent or fails to parse, so existing - * setups keep working until the user renames the variable. - */ readonly deprecatedEnv?: string; readonly parse?: (raw: string) => unknown; readonly default?: unknown; }; -/** - * A declared config-key rename: `key` (snake_case, as written on disk) is - * deprecated in favor of `replacement`. While the old key is present in the - * user's config file the service reports a warning diagnostic; the old value - * is NOT honored — only `replacement` (or the section default) applies. - */ export interface ConfigKeyDeprecation { readonly key: string; readonly replacement: string; - /** Optional extra guidance appended to the generated warning message. */ readonly message?: string; } @@ -207,11 +195,6 @@ export interface IConfigService { readonly ready: Promise; readonly onDidChangeConfiguration: Event; readonly onDidSectionChange: Event; - /** - * Fired when the diagnostics list changes (load / reload / env overlay - * re-application), carrying the full current list — including an empty - * list when the last diagnostic clears. - */ readonly onDidChangeDiagnostics: Event; get(domain: string): T; inspect(domain: string): ConfigInspectValue; diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 83c8e80c1..057aeb52c 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -367,7 +367,6 @@ export class ConfigService extends Disposable implements IConfigService { return [...this.diagnosticsList]; } - /** Append a diagnostic, skipping exact duplicates (rebuilds re-run the same checks). */ private pushDiagnostic(diagnostic: ConfigDiagnostic): void { const duplicate = this.diagnosticsList.some( (existing) => diff --git a/packages/agent-core-v2/src/app/file/fileService.ts b/packages/agent-core-v2/src/app/file/fileService.ts index 0e1b85cbf..71ee5ef64 100644 --- a/packages/agent-core-v2/src/app/file/fileService.ts +++ b/packages/agent-core-v2/src/app/file/fileService.ts @@ -43,12 +43,6 @@ export interface IFileService { export const IFileService: ServiceIdentifier = createDecorator('fileService'); -/** - * The upload id shape every `fileId`-addressed store may rely on. Ids are - * minted by `IFileService.save` (`f_`); anything else is not an upload - * and must never reach a storage key — the character whitelist is what keeps - * a caller-supplied id from escaping its storage scope (`..`, separators). - */ export const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/; export function isFileId(value: string): boolean { diff --git a/packages/agent-core-v2/src/app/mcpConfig/configLoader.ts b/packages/agent-core-v2/src/app/mcpConfig/configLoader.ts index 80504003e..2e200e954 100644 --- a/packages/agent-core-v2/src/app/mcpConfig/configLoader.ts +++ b/packages/agent-core-v2/src/app/mcpConfig/configLoader.ts @@ -39,9 +39,7 @@ export interface LoadMcpServersInput { } export interface LoadMcpServersDetailedResult { - /** Later layers override earlier ones with the same key. */ readonly servers: Record; - /** The file each effective entry was last defined in. */ readonly origins: Record; } @@ -51,10 +49,6 @@ export async function loadMcpServers( return (await loadMcpServersDetailed(input)).servers; } -/** - * {@link loadMcpServers} plus the defining-file origin of every effective - * entry, for management surfaces that show where a server came from. - */ export async function loadMcpServersDetailed( input: LoadMcpServersInput, ): Promise { diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts index dbded9e44..8755b2683 100644 --- a/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagement.ts @@ -12,11 +12,6 @@ export type GlobalMcpServerConfig = McpServerConfig & { readonly name: string }; export interface McpManagedServer { readonly name: string; - /** - * Mutable (user-level) entries carry the full config so edit UIs can - * prefill values; read-only entries are redacted to sorted key lists - * (`envKeys` / `headerKeys`) and never disclose secret values. - */ readonly config: McpServerConfig | McpServerConfigView; readonly source: McpServerSource; readonly origin: string; @@ -25,11 +20,8 @@ export interface McpManagedServer { } export interface McpServerTestTarget { - /** Registry-resolved by name when `server` is omitted. */ readonly name?: string; - /** Inline config probes as-is — nothing has to be saved first. */ readonly server?: GlobalMcpServerConfig; - /** Project layers join the resolution; also the stdio working directory. */ readonly cwd?: string; } @@ -38,21 +30,14 @@ export interface McpServerTestResult { readonly output: string; } -/** - * Stable address of one catalog entry: a global (file-layer) server by name, - * or a plugin server by plugin id + manifest-local server name. - */ export type McpServerLocator = | { readonly source: 'global'; readonly name: string } | { readonly source: 'plugin'; readonly pluginId: string; readonly serverName: string }; -/** Locator-addressed catalog entry with the redacted config view. */ export interface McpServerDescriptor { - /** `global:` / `plugin::`, URL-encoded. */ readonly serverId: string; readonly locator: McpServerLocator; readonly runtimeName: string; - /** Canonical credential URL for remote servers; undefined for stdio. */ readonly canonicalUrl?: string; readonly origin: McpServerSource; readonly config: McpServerConfigView; @@ -93,10 +78,6 @@ export interface McpServerAuthFlowHandle { } export interface McpAuthStatusQuery extends McpRegistryQuery { - /** - * Omitted preserves implicit OAuth detection, `false` stays offline, and - * `true` verifies every OAuth candidate through a real connection. - */ readonly verify?: boolean; } @@ -107,67 +88,41 @@ export interface IMcpManagementService { getServer(name: string, query?: McpRegistryQuery): Promise; - /** Writes the user-level file; rejects read-only collisions. Returns the refreshed list. */ addServer( server: GlobalMcpServerConfig, query?: McpRegistryQuery, ): Promise; - /** Updates an existing user-level entry; rejects read-only collisions. Returns the refreshed list. */ updateServer( server: GlobalMcpServerConfig, query?: McpRegistryQuery, ): Promise; - /** Removes a user-level entry; rejects read-only collisions. Returns the refreshed list. */ removeServer(name: string, query?: McpRegistryQuery): Promise; testServer(target: McpServerTestTarget): Promise; - /** - * Legacy auth-status surface: per-server OAuth state over the registry - * catalog. Omitted preserves the legacy implicit-OAuth probe for unpinned - * servers without stored credentials; `verify: false` is fully offline; - * `verify: true` probes every candidate. Probes may refresh or invalidate - * stored credentials and broadcast the events. - */ listAuthStatuses(query?: McpAuthStatusQuery): Promise; - /** - * The locator-addressed catalog plus a batched real-connection probe of - * every OAuth candidate; a probe that hits an expired grant may refresh or - * invalidate stored credentials and broadcast the events. A runtime name - * shared by enabled entries cannot be probed (or credentialed) - * unambiguously and reports `unavailable`. - */ inspectServers( targets?: readonly McpServerLocator[], query?: McpRegistryQuery, ): Promise; - /** - * Resolve a legacy name-only auth target: exactly one enabled entry may - * own the runtime name — under a collision the caller cannot tell which - * credential the flow acts on, so it rejects instead of guessing. - */ resolveServerByName(name: string, query?: McpRegistryQuery): Promise; - /** Begin an interactive OAuth flow for a remote server. */ beginServerAuth( locator: McpServerLocator, query?: McpRegistryQuery, ): Promise; - /** Await the browser callback and finish the code exchange. Unknown flow → request.invalid. */ completeServerAuth( handle: McpServerAuthFlowHandle, options?: { readonly signal?: AbortSignal }, ): Promise; - /** Tear down a flow without finishing it; unknown flows are ignored. */ cancelServerAuth(handle: Pick): Promise; - /** Clear stored credentials; the invalidation event reaches live sessions. */ resetServerAuth(locator: McpServerLocator, query?: McpRegistryQuery): Promise; } diff --git a/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts index 24f19ddf2..82e50ad6b 100644 --- a/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts +++ b/packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts @@ -567,7 +567,6 @@ function requireOAuthMcpConfig(name: string, input: McpServerConfig): McpRemoteS return config; } -/** Stable wire id of a locator: `global:` / `plugin::`. */ export function mcpServerId(locator: McpServerLocator): string { if (locator.source === 'global') return `global:${encodeURIComponent(locator.name)}`; return `plugin:${encodeURIComponent(locator.pluginId)}:${encodeURIComponent(locator.serverName)}`; diff --git a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts index 1c63e66f0..b3d25b916 100644 --- a/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts +++ b/packages/agent-core-v2/src/app/mcpRegistry/mcpRegistry.ts @@ -6,29 +6,19 @@ export type McpServerSource = 'global' | 'plugin' | 'caller'; export interface McpRegistryPluginOrigin { readonly id: string; - /** Manifest-local server name (without the `plugin-:` runtime prefix). */ readonly name: string; } export interface McpRegistryEntry { - /** Runtime name — for plugin entries the renamed `plugin-:` form. */ readonly name: string; - /** Final effective config after source-specific transforms. */ readonly config: McpServerConfig; readonly source: McpServerSource; - /** global: the defining file path; plugin: the plugin id; caller: `'caller'`. */ readonly origin: string; - /** True only for user-level global entries — the management API writes there. */ readonly mutable: boolean; readonly plugin?: McpRegistryPluginOrigin; } export interface McpRegistryQuery { - /** - * When set, the project-root and project-local layers join the global - * source. Session-scoped resolutions pass the session workDir; the - * process-global management plane usually omits it. - */ readonly cwd?: string; } @@ -37,15 +27,8 @@ export interface IMcpRegistryService { list(query?: McpRegistryQuery): Promise; - /** First match wins on a runtime-name collision (globals list first). */ get(name: string, query?: McpRegistryQuery): Promise; - /** - * Session-runtime resolution for one server name — the entry a live - * session should actually run, as opposed to the management view which - * lists every collision side by side. Returns `undefined` when no source - * currently defines the name. - */ resolveRuntimeTarget(name: string, query?: McpRegistryQuery): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 3c0a1e144..58a182a8b 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -16,29 +16,18 @@ export interface SessionSummary { readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; - /** Archive time (epoch ms); absent for sessions archived before the field - * existed — callers fall back to `updatedAt` for display. */ readonly archivedAt?: number; readonly custom?: Record; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } export interface SessionListQuery { - /** - * Restrict to sessions persisted under any of these workspace ids. A single - * workspace is `[id]`; callers resolving a legacy split bucket (one - * directory, several id spellings — see `IWorkspaceAliases.resolveAliasIds`) - * pass the whole alias set and get one merged listing. Absent lists every - * bucket. - */ readonly workspaceIds?: readonly string[]; readonly sessionId?: string; readonly includeArchived?: boolean; readonly limit?: number; readonly childOf?: string; - /** Keyset cursor: the page strictly older than this session id. */ readonly before?: string; - /** Keyset cursor: the page strictly newer than this session id. */ readonly after?: string; } @@ -51,35 +40,19 @@ export type SessionIndexState = 'uninitialized' | 'preparing' | 'ready' | 'degra export interface SessionIndexStatus { readonly state: SessionIndexState; - /** Published read-model generation; absent until the first projection. */ readonly generation?: number; - /** Why the index last entered `degraded` (authoritative fallback). */ readonly reason?: string; - /** How many times the index entered `degraded` in this process. */ readonly degradedCount: number; } export interface ISessionIndex { readonly _serviceBrand: undefined; - /** - * Open the read model and make it servable: open the query store, create - * the schema, restore the published generation (running the initial - * projection when none exists), and start background reconciliation. - * Single-flight; a no-op when the read-model flag is off. - */ prepare(options?: { deadlineMs?: number }): Promise; status(): SessionIndexStatus; get(id: string): Promise; - /** Recency-ordered keyset page over the persisted session set. */ listRecent(query: SessionListQuery): Promise>; - /** Materialized count over the given workspace-id set. */ count(query: SessionCountQuery): Promise; - /** - * The one write: evict a deleted session's derived/cached state so `get` - * stops answering for the id — the authoritative record (the session - * directory) is deleted by the caller (`sessionLifecycle.delete`). - */ remove(id: string): Promise; } @@ -89,22 +62,9 @@ export const ISessionIndex: ServiceIdentifier = export interface ISessionIndexMirror { readonly _serviceBrand: undefined; - /** - * Enqueue the latest summary of a session for mirroring into the read - * model. Synchronous, bounded, and coalescing (only the newest summary per - * session is kept); never throws — failures stay dirty and are healed by - * reconciliation. - */ record(summary: SessionSummary): void; - /** Summaries accepted but not yet flushed (read-your-writes window). */ pending(): readonly SessionSummary[]; - /** - * Forget a session on the delete path: drop any queued summary and wait - * out an in-flight flush that may still carry it, so the caller's - * follow-up query-store delete is not resurrected by the mirror. - */ evict(id: string): Promise; - /** Flush everything currently queued; resolves with the queue empty. */ drain(): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts index 28230096e..6d0509927 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts @@ -17,25 +17,14 @@ export function sessionCountersCollection(generation: number): string { return `sessionCounters:g${generation}`; } -/** - * The ordered recency column for a generation. Column names are store-wide, - * so the column is namespaced per generation: two coexisting generations - * (one published, one being projected) then walk disjoint ordered - * structures and can never interleave into each other's pages. The stored - * record carries the same-named field — the engine orders by the column and - * its cross-shard merge compares by the value field of that name — and the - * index strips it again on every read. - */ export function recencyColumn(generation: number): string { return `g${generation}:updatedAt`; } -/** Attach the generation's recency field to a summary for storage. */ export function withRecencyField(generation: number, summary: SessionSummary): SessionSummary { return { ...summary, [recencyColumn(generation)]: summary.updatedAt }; } -/** Remove the generation's recency field from a stored record. */ export function stripRecencyField(generation: number, record: SessionSummary): SessionSummary { const key = recencyColumn(generation); if (!(key in record)) return record; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts index 3483f9e17..fc50863f2 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -44,7 +44,6 @@ export interface ReconcileResult { readonly removed: number; } -/** One consistent pass over the authoritative session metadata set. */ export interface AuthoritativeScan { readonly summaries: SessionSummary[]; readonly counts: Map; @@ -61,14 +60,6 @@ export class SessionIndexProjector { constructor(private readonly deps: SessionIndexProjectorDeps) {} - /** - * The projection's scan: joins a running shared scan, reuses one that - * settled within the reuse window, or starts a fresh one. The projection - * publishes a point-in-time derived model by design, so a just-finished - * snapshot is safe for it (the mirror queue and reconciliation heal the - * gap) — and this is what keeps a fast first read + kicked projection - * from scanning the directory tree twice. - */ sharedScan(): Promise { const slot = this.scanSlot; if (slot !== undefined && (!slot.settled || Date.now() < slot.reusableUntil)) { @@ -77,15 +68,6 @@ export class SessionIndexProjector { return this.startScan(); } - /** - * A fallback read's scan: joins a scan that is still in flight or starts a - * fresh one. A settled snapshot is NEVER served to a read — it could - * predate a session this process just created, breaking read-your-writes. - * Joining an in-flight scan is NOT the same freshness as enumerating here - * and now: the scan may have started (and passed a directory) before this - * call, so the caller folds the mirror's pending queue into the result — - * every pending entry is known to be durable on disk. - */ sharedScanForRead(): Promise { const slot = this.scanSlot; if (slot !== undefined && !slot.settled) return slot.promise; @@ -106,7 +88,6 @@ export class SessionIndexProjector { return slot.promise; } - /** Scan the authoritative set into a fresh generation and publish it. */ async project(generation: number): Promise { const scan = this.sharedScan(); try { @@ -164,7 +145,6 @@ export class SessionIndexProjector { return { generation, sessions: summaries.length }; } - /** Re-scan the authoritative set and repair the published generation. */ async reconcile(generation: number): Promise { const { queryStore, log } = this.deps; const collection = sessionCollection(generation); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index df259588f..e29823349 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -99,8 +99,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { }); } - /** The reconcile loop runs only while the read model is in play — starting - * it unconditionally would spin an interval for every flag-off host. */ private ensureReconcileTimer(): void { if (!this.reconcileTimer.isSet()) { this.reconcileTimer.cancelAndSet(() => void this.tick(), RECONCILE_INTERVAL_MS); @@ -187,7 +185,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } } - /** Test/ops hook: reconcile the published generation against disk now. */ async reconcileNow(): Promise { if (!this.readModelEnabled()) return; const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); @@ -196,14 +193,11 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { await this.projector.reconcile(manifest.seq); } - /** Test/ops hook: project a fresh generation now (single-flight). */ async reprojectNow(): Promise { if (!this.readModelEnabled()) return; await this.ensureProjection(); } - /** Test hook: stop the background reconcile loop, so measurement windows - * contain only the operations under test. */ stopReconcileLoop(): void { this.reconcileTimer.cancel(); } @@ -279,16 +273,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { ); } - /** - * Evict a deleted session's derived state so `get` / `listRecent` stop - * answering for the id immediately: the authoritative directory is deleted - * by the caller (`sessionLifecycle.delete`), and the next projection would - * drop the entry anyway — this closes the stale-read window in between. The - * mirror queue is evicted first (waiting out an in-flight flush): reads - * fold the queue in for read-your-writes, and a late flush would otherwise - * resurrect the entry after the store delete. With the read model off - * there is no derived state to evict beyond the queue. - */ async remove(id: string): Promise { await this.mirror.evict(id); await this.withReadModel( @@ -299,12 +283,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { ); } - /** - * Serve `op` from the read model when possible, else from the authoritative - * path: flag off, not prepared yet (kicked here single-flight), preparing, - * or degraded (with a throttled re-prepare). Any read-model failure demotes - * to `degraded` — logged and counted — and falls back immediately. - */ private async withReadModel( op: (generation: number) => Promise, legacy: () => Promise, @@ -437,14 +415,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return total; } - /** - * Canonical keyset window: fetch `limit + 1` rows under `bounds`; when the - * window is full, re-fetch the boundary tie group (`updatedAt` equal to the - * window's minimum) and merge, so a page cut inside a same-millisecond tie - * group never drops or duplicates an item across pages. Rows are re-sorted - * into the canonical (`updatedAt` desc, `id` desc) order — the engine's - * cross-shard tie order is deterministic but not canonical. - */ private async windowedPage( fetch: (bounds: ColumnBounds, limit: number) => Promise, bounds: ColumnBounds, @@ -465,12 +435,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; } - /** - * Read-your-writes merge: pages fold in the mirror's queued summaries so a - * just-mutated session shows up before the flush lands. Cursor pages merge - * only the queued summaries that fall inside the page's canonical range - * (the queue is a tiny, transient window). - */ private mergePending( page: Page, query: SessionListQuery, @@ -503,13 +467,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; } - /** - * Resolve a keyset cursor id to its column bounds plus the exact - * tie-exclusion filter, in canonical order: strictly older (`before`) is - * `(updatedAt, id)` lexicographically below the cursor, strictly newer - * (`after`) is above. An unknown cursor id yields `undefined` — the caller - * answers an empty, terminal page. - */ private async resolveCursor( generation: number, query: SessionListQuery, @@ -621,20 +578,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return count; } - /** - * Collect the authoritative summaries behind a legacy read. While the read - * model is enabled but not yet ready, the kicked initial projection is - * scanning the same authoritative set, so the read joins that in-flight - * scan (or drives the one the projection will reuse) instead of running a - * second full directory scan. Flag-off hosts and the degraded fallback - * keep the targeted per-workspace enumeration. - * - * Either way the mirror's pending queue is folded in by id (pending - * entries win): every queued summary was recorded only after its - * `state.json` is durable, and a scan/enumeration that started before the - * write may legitimately have passed the directory already — the fold is - * what keeps read-your-writes on this path too. - */ private async collectAuthoritative( workspaceIds: readonly string[] | undefined, ): Promise { diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 1353298ae..698265881 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -32,9 +32,6 @@ export function recoverCwd(meta: Record): string | undefined { return undefined; } -/** The single construction path for summaries — field order is fixed so a - * stored summary deep-compares equal to a fresh projection of the same - * metadata document. */ export function buildSessionSummary(fields: { id: string; workspaceId: string; @@ -75,9 +72,6 @@ export function summaryMatchesChildOf( ); } -/** Deep-enough equality for reconciliation: the projection-relevant fields, - * with `custom` compared structurally (both sides are JSON-round-tripped - * values built by `buildSessionSummary`, so key order is stable). */ export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { return ( a.id === b.id && @@ -157,8 +151,6 @@ async function readMeta( } } -/** Bounded-concurrency map: resolves every item through `fn`, dropping - * `undefined` results, with at most `concurrency` calls in flight. */ export async function mapBounded( items: readonly T[], concurrency: number, diff --git a/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts b/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts index 95f1bfacc..ecd43d8f2 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudTransport.ts @@ -33,11 +33,7 @@ export interface CloudTransportOptions { readonly storage: IFileSystemStorageService; readonly deviceId: string; readonly endpoint?: string; - /** Bootstrapped home for the default endpoint's region resolution (the - install marker lives there, not necessarily under PYTHINKER_CODE_HOME). */ readonly homeDir?: string; - /** Pre-resolved marker opt-out from the host's bootstrap env (defaults to - reading PYTHINKER_CODE_REGION_MARKER from the process env). */ readonly readMarker?: boolean; readonly getAccessToken?: () => string | null | Promise; readonly fetchImpl?: typeof fetch; @@ -48,9 +44,7 @@ export interface CloudTransportOptions { } export const TELEMETRY_ENDPOINT = 'https://telemetry-logs.pythinker.com/v1/event'; -/** Do not change this Pythinker wire prefix. SigNoz dashboards query `pfc_*` events. */ export const SERVER_EVENT_PREFIX = 'pfc_'; -/** Do not change this Pythinker identity prefix. SigNoz device queries depend on it. */ export const USER_ID_PREFIX = 'pfc_device_id_'; export const DISK_EVENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; export const RETRY_BACKOFFS_MS = [1_000, 4_000, 16_000] as const; diff --git a/packages/agent-core-v2/src/features/externalHooks/app/externalHooksRunner.ts b/packages/agent-core-v2/src/features/externalHooks/app/externalHooksRunner.ts index 9550e55fa..7f1dabdf4 100644 --- a/packages/agent-core-v2/src/features/externalHooks/app/externalHooksRunner.ts +++ b/packages/agent-core-v2/src/features/externalHooks/app/externalHooksRunner.ts @@ -13,7 +13,6 @@ export interface ExternalHooksRunnerTriggerArgs { export interface IExternalHooksRunnerService { readonly _serviceBrand: undefined; readonly ready: Promise; - /** Fired after the hook index is (re)built — initial load and plugin reloads. */ readonly onDidReload: Event; trigger(event: string, args?: ExternalHooksRunnerTriggerArgs): Promise; triggerBlock( diff --git a/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts b/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts index 251b20df2..9df82ec01 100644 --- a/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts +++ b/packages/agent-core-v2/src/features/skill/session/skillCatalog.ts @@ -12,12 +12,6 @@ export interface ISessionSkillCatalog { readonly onDidChange: Event; load(): Promise; reload(): Promise; - /** - * Wire-friendly snapshot of the merged catalog: every skill as a - * `SkillSummary`, resolved after `ready`. Unlike the `catalog` property - * (a live object whose methods do not cross a wire), the result is plain - * serializable data. - */ list(): Promise; } diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index b062e9c73..b446de79a 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -29,7 +29,6 @@ export async function git(cwd: string, args: readonly string[]): Promise }); } -/** `git` that returns null instead of throwing when the command fails. */ export async function tryGit(cwd: string, args: readonly string[]): Promise { try { return await git(cwd, args); @@ -75,11 +74,6 @@ export async function worktreeAdd( await git(cwd, ['worktree', 'add', path, '-b', branch, base]); } -/** - * Removal is always `--force`: the caller's dirty check is the data-loss gate. - * A plain `git worktree remove` additionally refuses clean worktrees that - * contain initialized submodules, which must not strand a clean teardown. - */ export async function worktreeRemove(cwd: string, path: string): Promise { await git(cwd, ['worktree', 'remove', '--force', path]); } @@ -94,7 +88,6 @@ export async function mergeNoFf(cwd: string, branch: string): Promise { return branchTip(cwd, 'HEAD'); } -/** Changed files of `ref` relative to `base` (three-dot, i.e. since merge-base). */ export async function diffNameOnly( cwd: string, base: string, diff --git a/packages/agent-core-v2/src/features/tower/protocol/paths.ts b/packages/agent-core-v2/src/features/tower/protocol/paths.ts index ed652b139..9c3aa6b85 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/paths.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/paths.ts @@ -14,7 +14,6 @@ export const MISSIONS_INDEX = `${COMMS_DIR}/MISSIONS.md`; export const TOWER_NAME = 'tower'; export const BROADCAST_NAME = 'all'; -/** Local YYYYMMDD, used at the start of inbox/finding file names. */ export function dateStamp(now = new Date()): string { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); @@ -22,16 +21,11 @@ export function dateStamp(now = new Date()): string { return `${y}${m}${d}`; } -/** `YYYY-MM-DD` for review frontmatter. */ export function dateDash(now = new Date()): string { const stamp = dateStamp(now); return `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}`; } -/** - * Filesystem-safe slug: lowercase, alnum runs joined by `-`. CJK and other - * non-ASCII letters are dropped so names stay greppable everywhere. - */ export function slugify(text: string, maxLength = 60): string { const slug = text .toLowerCase() @@ -42,7 +36,6 @@ export function slugify(text: string, maxLength = 60): string { return slug.length > 0 ? slug : 'item'; } -/** Branch/PR targets become filename segments: `feat/x` → `feat-x`, `#12` → `pr12`. */ export function targetSlug(target: string): string { const cleaned = target.trim().replace(/^#/, 'pr'); return slugify(cleaned.replaceAll(/[/#]+/g, '-')); diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts index a266feb88..fa996ccf5 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/store.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -60,26 +60,9 @@ export class TowerProtocolError extends Error { export interface TowerInitResult { readonly base: string; readonly created: boolean; - /** - * Roster names retired while adopting a workspace last driven by a - * different session. Empty on creation and on same-session re-init. - */ readonly retiredAgents: readonly string[]; - /** - * The branch checked out in the main worktree at init time ('HEAD' when - * detached). Merges stay blocked while this differs from `base`. - */ readonly checkout: string; - /** - * The base argument dropped because the existing workspace already records - * a different base — re-init never resets recorded state. - */ readonly ignoredBase?: string; - /** - * Ids of missions still holding their scope (not merged, not abandoned) — - * on re-init these are the carried-over missions a new plan must either - * continue or abandon. Empty on creation. - */ readonly openMissions: readonly string[]; } @@ -88,7 +71,6 @@ export interface TowerPlanInput { readonly scope: readonly string[]; readonly tasks?: readonly string[]; readonly deps?: readonly string[]; - /** Defaults to `build`. `survey` missions are read-only and reserve no scope. */ readonly kind?: TowerMissionKind; } @@ -126,9 +108,7 @@ export interface TowerMissionPatch { readonly blocker?: string; readonly clearBlockers?: boolean; readonly taskDone?: string; - /** Tower-only: assign the roster agent that owns this mission. */ readonly owner?: string; - /** Tower-only: replace the mission's scope globs (logged; widens the merge gate). */ readonly scope?: readonly string[]; } @@ -148,7 +128,6 @@ function isOpenMission(mission: Pick): boolean { } export class TowerStore { - /** Absolute path of the main checkout (the session working directory). */ constructor(readonly repoRoot: string) {} async isInitialized(): Promise { @@ -160,15 +139,6 @@ export class TowerStore { } } - /** - * Create the `.tower/` skeleton. Safe to call twice — an existing - * workspace is reported, never reset. When the existing workspace was last - * driven by a *different* session it is adopted instead: roster entries the - * current session did not spawn are retired (engine agent ids are - * session-scoped, so after a restart the dead entries would alias this - * session's freshly issued `agent-N` ids), missions/worktrees survive, and - * an `adopt` line marks the session boundary in the activity log. - */ async init(sessionId?: string, base?: string): Promise { if (!(await isInsideRepo(this.repoRoot))) { throw new TowerProtocolError( @@ -232,17 +202,10 @@ export class TowerStore { return { base: resolvedBase, created: true, retiredAgents: [], checkout, openMissions: [] }; } - /** Branch checked out in the main worktree, or 'HEAD' when detached. */ private async checkedOutBranch(): Promise { return (await tryGit(this.repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'HEAD'; } - /** - * Retire roster entries spawned by other sessions and restamp the state - * with the current session id. The `adopt` log line is written on every - * session change — even with nothing to retire — so id collisions across - * the boundary stay attributable when reading the activity log. - */ private async adoptForeignRoster( state: TowerState, sessionId: string | undefined, @@ -265,7 +228,6 @@ export class TowerStore { return stale.map((agent) => agent.name); } - /** Add `.tower/` to `.git/info/exclude` (repo-local; tracked .gitignore stays untouched). */ private async ensureGitExclude(): Promise { const gitDir = (await readGitDir(this.repoRoot)) ?? join(this.repoRoot, '.git'); const excludePath = join(gitDir, 'info', 'exclude'); @@ -344,10 +306,6 @@ export class TowerStore { return state.roster.agents.find((agent) => agent.name === name); } - /** - * Register a spawned agent. Returns the existing entry when the name is - * already taken — callers implement "resume instead of duplicate spawn". - */ findByName(state: TowerState, name: string): TowerRosterEntry | undefined { return this.findAgent(state, name); } @@ -415,12 +373,6 @@ export class TowerStore { return missions; } - /** - * Conservative overlap check over the scopes that reserve write access — - * i.e. `build` missions only. Survey scopes are informational and reserve - * nothing, so they never conflict. Two build scopes conflict when one is a - * path prefix of the other after stripping trailing `**` / `*` wildcards. - */ private assertScopesDisjoint(missions: readonly TowerMission[]): void { const scopes: Array<{ readonly id: string; readonly raw: string; readonly stem: string }> = []; for (const mission of missions) { @@ -582,7 +534,6 @@ export class TowerStore { return rel; } - /** Newest-first messages addressed to `callerName` or broadcast. The tower sees everything. */ async readInbox(callerName: string, limit: number): Promise { let files: string[]; try { @@ -998,7 +949,6 @@ export class TowerStore { return join(this.repoRoot, rel); } - /** Exclusive-create write; on a name clash appends `-2`, `-3`, … before the extension. */ private async writeUnique(rel: string, content: string): Promise { const dot = rel.lastIndexOf('.'); const stem = dot === -1 ? rel : rel.slice(0, dot); diff --git a/packages/agent-core-v2/src/features/tower/protocol/types.ts b/packages/agent-core-v2/src/features/tower/protocol/types.ts index 31798f43b..d351e7252 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/types.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -1,26 +1,13 @@ export type TowerAgentKind = 'worker' | 'reviewer'; export interface TowerRosterEntry { - /** Display/route name, e.g. `agent-build`, `reviewer-a`. Unique per workspace. */ readonly name: string; - /** Engine agent id (e.g. `agent-3`); the tower is always `main`. */ readonly agentId: string; - /** - * Session that spawned this agent. Engine agent ids are only unique within - * one session — after a CLI restart a new session reissues `agent-0`, … — so - * an entry is meaningful (resumable, dereferenceable) only in its own - * session. TowerInit retiring a foreign session's entries is what keeps - * id→name resolution unambiguous. - */ readonly sessionId?: string; readonly kind: TowerAgentKind; - /** Workers: the mission they own. */ readonly missionId?: string; - /** Reviewers: the branch they are assigned to review. */ readonly reviewTarget?: string; - /** Workers: worktree slot, e.g. `wt-1`. */ readonly worktree?: string; - /** Workers: their branch, e.g. `feat/vulkan-build`. */ readonly branch?: string; readonly spawnedAt: string; } @@ -29,13 +16,6 @@ export interface TowerRoster { readonly agents: TowerRosterEntry[]; } -/** - * Lifecycle of a mission. `merged` (landed) and `abandoned` (given up without - * merging) are the two closed states: a closed mission stops reserving its - * scope, counts as satisfied for dependents, and drops out of merge-conflict - * checks. Abandoning is tower-only; the mission stays visible as the audit - * trail. - */ export type TowerMissionStatus = | 'planned' | 'active' @@ -45,13 +25,6 @@ export type TowerMissionStatus = | 'merged' | 'abandoned'; -/** - * `build` missions change code: their scope reserves write access (plan-time - * disjoint check, merge-time containment) and they merge through the full - * review gate. `survey` missions are read-only investigations: their scope is - * informational only (reserves nothing), and their merge is a zero-diff - * formality that closes the mission without a git merge. - */ export type TowerMissionKind = 'build' | 'survey'; export interface TowerMissionTask { @@ -64,7 +37,6 @@ export interface TowerMission { readonly title: string; readonly slug: string; kind: TowerMissionKind; - /** picomatch globs; mutable only through `updateMission` (tower, logged). */ scope: string[]; readonly branch: string; readonly worktree: string; @@ -72,7 +44,6 @@ export interface TowerMission { status: TowerMissionStatus; owner?: string; tasks: TowerMissionTask[]; - /** Decision log, oldest first. */ notes: string[]; blockers: string[]; } @@ -80,15 +51,8 @@ export interface TowerMission { export interface TowerState { readonly version: 1; readonly base: string; - /** `pr` is reserved for a future gh-backed mode; v1 always runs `branch`. */ readonly mode: 'branch' | 'pr'; readonly createdAt: string; - /** - * Session that most recently ran TowerInit here. A different session - * re-initializing adopts the workspace: roster entries it did not spawn are - * retired (their engine agent ids are meaningless outside their own - * session), missions and worktrees are preserved. - */ sessionId?: string; roster: TowerRoster; missions: TowerMission[]; @@ -106,7 +70,6 @@ export interface TowerReviewInfo { readonly round: number; readonly status: string; readonly merge: string; - /** Branch tip the review was written against; merge gate compares it. */ readonly reviewedCommit: string; readonly date: string; readonly file: string; diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index b4bb754c5..a50eb5c83 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -325,7 +325,6 @@ export class TowerSpawnTool implements ITowerSpawnTool { }; } - /** Briefings are code-assembled — the tower LLM only supplies `instructions`. */ private async buildPrompt( args: TowerSpawnToolInput, store: TowerStore, diff --git a/packages/agent-core-v2/src/features/tower/tools/support.ts b/packages/agent-core-v2/src/features/tower/tools/support.ts index d18370a47..11aa7753b 100644 --- a/packages/agent-core-v2/src/features/tower/tools/support.ts +++ b/packages/agent-core-v2/src/features/tower/tools/support.ts @@ -8,7 +8,6 @@ import { import type { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ExecutableToolResult } from '#/tool/toolContract'; -/** The store root is the main checkout holding `.tower/`. */ export function newTowerStore(sessionContext: ISessionContext): TowerStore { return new TowerStore(resolveTowerRepoRoot(sessionContext.cwd)); } @@ -16,19 +15,10 @@ export function newTowerStore(sessionContext: ISessionContext): TowerStore { export const TOWER_MAIN_AGENT_ONLY = 'Tower orchestration tools are only supported by the main agent.'; -/** - * Resolve the caller's tower identity. The main agent is the control tower; - * a spawned worker/reviewer is looked up in the roster by its agent id. - */ export function callerName(agentId: string, store: TowerStore, state: TowerState): string { return store.resolveCallerName(state, agentId); } -/** - * Run a tower tool body, mapping expected protocol/git failures to error - * results — their messages are written as next-step guidance for the model. - * Unexpected (programming) errors keep propagating. - */ export async function runTowerTool( execute: () => Promise, ): Promise { diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts index 2e960ec95..97a4bb2e8 100644 --- a/packages/agent-core-v2/src/features/tower/tower.ts +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -13,12 +13,6 @@ export const TOWER_TOOL_NAMES = [ 'TowerStatus', ] as const; -/** - * Profile name of tower-spawned worker/reviewer agents. TowerSpawn pins these - * agents to the `auto` permission mode at spawn (they run detached and - * unattended), and `broadcastPermissionMode` skips them, so a session-wide - * mode switch never moves them off `auto`. - */ export const TOWER_WORKER_PROFILE = 'tower-worker'; export const TOWER_FLAG_ID = 'tower'; diff --git a/packages/agent-core-v2/src/features/tower/towerRateLimit.ts b/packages/agent-core-v2/src/features/tower/towerRateLimit.ts index 102594fd4..aa2297419 100644 --- a/packages/agent-core-v2/src/features/tower/towerRateLimit.ts +++ b/packages/agent-core-v2/src/features/tower/towerRateLimit.ts @@ -1,11 +1,8 @@ import { createDecorator } from '#/_base/di/instantiation'; export interface TowerRateLimitSnapshot { - /** Effective tower spawn budget: governor capacity clamped to the max. */ readonly budget: number; - /** Tower agents currently running (acquired, not yet released). */ readonly inflight: number; - /** Epoch ms while which new spawns are refused; null when unblocked. */ readonly blockedUntil: number | null; } diff --git a/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts b/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts index 8d943c390..e4e84b86a 100644 --- a/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts +++ b/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts @@ -6,9 +6,7 @@ import { export const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2_000; export const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 180_000; -/** Tower-only: how long new spawns stay paused after a 429 episode. */ export const TOWER_SPAWN_PAUSE_MS = 60_000; -/** Tower-only: ceiling the capacity may recover to. */ export const TOWER_MAX_BUDGET = 16; export class RateLimitCapacityGovernor { diff --git a/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts b/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts index 00b71a64f..f00d06c4d 100644 --- a/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts +++ b/packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts @@ -2,16 +2,6 @@ import type { ModelRecord, ModelsSection } from './model'; const TOOL_USE_CAPABILITY = 'tool_use'; -/** - * True when a model can plausibly drive an agent turn. - * - * A model is rejected only on positive evidence that it cannot: it declares - * capabilities and `tool_use` is absent (embedding, rerank and vision-only - * entries), or it declares a non-positive context window. A record that - * declares no capabilities at all — common for hand-configured - * OpenAI-compatible endpoints — stays eligible, so an unknown provider can - * never leave the caller with no candidate at all. - */ export function isEligibleDefaultModel(record: ModelRecord): boolean { const context = effectiveContextSize(record); if (context !== undefined && context <= 0) return false; @@ -20,13 +10,6 @@ export function isEligibleDefaultModel(record: ModelRecord): boolean { return capabilities.some((entry) => entry.trim().toLowerCase() === TOOL_USE_CAPABILITY); } -/** - * Rank eligible model ids best-first. - * - * Declared tool use wins over an undeclared capability set, then the larger - * usable context window, then a stable id sort so the outcome never depends on - * object key order or catalog iteration. - */ export function rankDefaultModelCandidates(models: ModelsSection): string[] { return Object.entries(models) .filter(([, record]) => isEligibleDefaultModel(record)) @@ -43,16 +26,6 @@ export function rankDefaultModelCandidates(models: ModelsSection): string[] { .map((candidate) => candidate.id); } -/** - * The default model the given catalog should settle on. - * - * Any existing default is returned untouched — including one this policy would - * not have picked itself, and one naming a model the registry has not loaded - * yet, so neither a deliberate choice nor an env-pinned pointer is ever - * clobbered. A fallback is chosen only when nothing is set at all. Returns - * `undefined` when no model is eligible, leaving readiness honestly false - * rather than pointing at a model that cannot serve a turn. - */ export function resolveDefaultModel( models: ModelsSection, current: string | undefined, diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/kosong/model/model.ts index d3db76d93..0e5a33db2 100644 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ b/packages/agent-core-v2/src/kosong/model/model.ts @@ -64,14 +64,6 @@ export interface IModelService { readonly _serviceBrand: undefined; readonly ready: Promise; - /** - * Resolves once any in-flight default-model adoption has finished, including - * the config write it triggers. - * - * Adoption is kicked off from the synchronous `loadAll` the config bridge - * calls, so without awaiting this a caller can return to its own caller - * before the adopted default has been persisted. - */ readonly settled: Promise; readonly onDidChangeModels: Event; readonly onDidChangeDefaultModel: Event; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index 4494f8758..91bed6c5d 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -499,15 +499,6 @@ function mapAudioUrlToInputItem(url: string): unknown { return null; } -/** - * The ChatGPT Codex gateway rejects `max_output_tokens` outright — it answers - * `400 {"detail":"Unsupported parameter: max_output_tokens"}` — while the - * public OpenAI Responses API requires it to honour a completion budget. The - * budget resolver always produces one, so the parameter has to be dropped for - * this host or every Codex turn fails before it starts. Applied once, after the - * request kwargs are assembled: a configured `maxOutputTokens` and a per-turn - * completion budget both land in the same field. - */ export function rejectsMaxOutputTokens(baseUrl: string | undefined): boolean { if (baseUrl === undefined) return false; let host: string; diff --git a/packages/agent-core-v2/src/mcpCore/configView.ts b/packages/agent-core-v2/src/mcpCore/configView.ts index 0c35e5f5e..f9dcd95ef 100644 --- a/packages/agent-core-v2/src/mcpCore/configView.ts +++ b/packages/agent-core-v2/src/mcpCore/configView.ts @@ -8,7 +8,6 @@ export type McpServerConfigView = readonly headerKeys?: readonly string[]; }); -/** Project a full effective config into its wire-facing view. */ export function toMcpServerConfigView(config: McpServerConfig): McpServerConfigView { if (config.transport === 'stdio') { const { env, ...safe } = config; diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 98581ee11..72a94554c 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -37,12 +37,6 @@ interface InternalEntry { export type McpStatusListener = (entry: McpServerEntry) => void; -/** - * The consumer surface of a connection manager. `McpConnectionManager` - * implements it directly; the session domain's `MergedMcpConnectionView` - * implements it over a workspace manager plus a session overlay, so session - * and agent consumers never care which manager owns a server. - */ export interface McpConnectionView { readonly oauthService: McpOAuthService | undefined; list(): readonly McpServerEntry[]; @@ -297,11 +291,6 @@ export class McpConnectionManager implements McpConnectionView { return work; } - /** - * {@link reconnectAndJoin} queued behind any in-flight reconnect: a - * credential that lands while a reconnect is already running triggers one - * more pass instead of being absorbed by the stale run. - */ async reconnectAfterCurrent(name: string): Promise { const existing = this.inFlightReconnects.get(name); if (existing !== undefined) await existing.catch(() => undefined); @@ -572,11 +561,6 @@ function stderrTail(client: RuntimeMcpClient | undefined): string | undefined { return snapshot.trimEnd(); } -/** - * Structural equality for effective configs, backing the idempotent-connect - * guard (config reconcilers and explicit callers may issue the same upsert) - * and the management plane's change detection. - */ export function mcpServerConfigsEqual(a: McpServerConfig, b: McpServerConfig): boolean { return stableConfigJson(a) === stableConfigJson(b); } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts index cb4141f86..215ca3b14 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/callback-server.ts @@ -8,13 +8,6 @@ export interface CallbackResult { export interface CallbackServer { readonly redirectUri: string; - /** - * Resolves with the OAuth callback payload, or rejects when: - * - `signal` aborts → AbortError - * - `timeoutMs` elapses → Error('OAuth callback timed out') - * - the user's authorization server returns an error → Error('OAuth error: ') - * - `close()` is called → OAuthCallbackClosedError - */ waitForCode(opts: { signal?: AbortSignal; timeoutMs?: number }): Promise; close(): Promise; } diff --git a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts index c83e94263..251f1767f 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/provider.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/provider.ts @@ -21,7 +21,6 @@ import { canonicalMcpOAuthResource, mcpOAuthStoreKey, type McpOAuthStore } from const TOKENS_SUFFIX = '-tokens.json'; const CLIENT_SUFFIX = '-client.json'; const DISCOVERY_SUFFIX = '-discovery.json'; -/** Sidecar `-meta.json` suffix; the service scans these on startup. */ export const META_SUFFIX = '-meta.json'; const PASSIVE_REDIRECT_URI = 'http://127.0.0.1:3118/callback'; @@ -29,7 +28,6 @@ export interface StoredMcpOAuthTokens extends OAuthTokens { readonly obtained_at?: number; } -/** Sidecar `-meta.json` record mapping a store key back to its server. */ export interface McpOAuthStoreMeta { readonly serverName: string; readonly serverUrl: string; @@ -42,13 +40,10 @@ export interface McpOAuthProviderOptions { readonly clientLabel?: string; readonly clientName?: string; readonly now?: () => number; - /** Called after tokens are persisted (login, exchange, or refresh). */ readonly onTokensSaved?: (tokens: StoredMcpOAuthTokens) => void; - /** Called after any credential invalidation, including SDK-driven ones. */ readonly onCredentialsInvalidated?: ( scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', ) => void; - /** Receives every in-flight token-grant promise so shutdown can drain it. */ readonly track?: (operation: Promise) => void; } @@ -184,11 +179,6 @@ export class McpOAuthClientProvider implements OAuthClientProvider { await this.tokenTransaction.save(tokens); } - /** - * Wrap the fetch used by the SDK's OAuth flow. Refresh-token grants for the - * same MCP identity are serialized, re-read from durable storage inside the - * lock, and committed before the lock is released. - */ createOAuthFetch(fetchFn: typeof fetch = globalThis.fetch): typeof fetch { return this.tokenTransaction.createFetch(fetchFn); } @@ -246,7 +236,6 @@ export class McpOAuthClientProvider implements OAuthClientProvider { this.onCredentialsInvalidated?.(scope); } - /** Explicit user-driven reset; unlike the SDK invalidation hook, never preserves tokens. */ async clearCredentials( scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', ): Promise { @@ -287,10 +276,6 @@ function registeredRedirectUri(info: OAuthClientInformationMixed | undefined): s return redirectUri; } -/** - * Route a transport's fetch through the provider's token transaction when one - * is attached, so refresh grants racing on the same credential serialize. - */ export function createMcpOAuthFetch( provider: OAuthClientProvider | undefined, fetchFn: typeof fetch | undefined, diff --git a/packages/agent-core-v2/src/mcpCore/oauth/service.ts b/packages/agent-core-v2/src/mcpCore/oauth/service.ts index 8d5cd7670..0315485fe 100644 --- a/packages/agent-core-v2/src/mcpCore/oauth/service.ts +++ b/packages/agent-core-v2/src/mcpCore/oauth/service.ts @@ -26,9 +26,7 @@ export interface McpOAuthServiceOptions { readonly resolveClientName?: () => string | undefined; readonly log?: Logger; readonly scheduler?: McpOAuthScheduler; - /** Per-request bound for OAuth-flow HTTP (discovery, registration, grants). */ readonly authRequestTimeoutMs?: number; - /** Upper bound for awaiting in-flight flows and refreshes during shutdown. */ readonly shutdownDrainTimeoutMs?: number; } @@ -47,23 +45,7 @@ export interface BeginAuthorizationOptions { export interface BeginAuthorizationResult { readonly authorizationUrl: URL; - /** - * Awaits the OAuth callback, validates `state`, exchanges the code for - * tokens, and persists them via the provider. Resolves on success; - * rejects on abort, timeout, or auth-server error. - * - * Handles sharing one underlying flow (concurrent `beginAuthorization` - * calls for the same credential) run the wait and the exchange exactly - * once: the first `complete()` call's `signal`/`timeoutMs` apply and the - * rest await the same outcome. - */ complete(opts?: { signal?: AbortSignal; timeoutMs?: number }): Promise; - /** - * Detaches this caller without finishing the flow. The callback listener - * stays active while another handle is attached and closes when the final - * handle detaches. Safe to call repeatedly; called automatically by - * `complete()`. - */ cancel(): Promise; } @@ -99,11 +81,9 @@ export type McpOAuthEvent = export type McpOAuthEventListener = (event: McpOAuthEvent) => void; -/** Offline credential snapshot for one server/resource identity. */ export interface McpOAuthTokenState { readonly hasTokens: boolean; readonly hasRefreshToken: boolean; - /** Absolute expiry in epoch ms, when the stored grant carries enough data. */ readonly expiresAt?: number; readonly expired: boolean; } @@ -153,7 +133,6 @@ export class McpOAuthService { return this.shutdown(); } - /** Returns the cached provider for `serverName` + `serverUrl`, constructing it on first use. */ getProvider(serverName: string, serverUrl: string | URL): McpOAuthClientProvider { const storeKey = mcpOAuthStoreKey(serverName, serverUrl); let provider = this.providers.get(storeKey); @@ -164,16 +143,10 @@ export class McpOAuthService { return provider; } - /** True once the provider has persisted tokens for this server/resource identity. */ async hasTokens(serverName: string, serverUrl: string | URL): Promise { return (await this.getProvider(serverName, serverUrl).tokens()) !== undefined; } - /** - * Offline view of the stored grant. `expired` is only computable when the - * tokens were written with an `obtained_at` stamp and carry `expires_in`; - * older or foreign writes without both are treated as non-expiring. - */ async tokenState(serverName: string, serverUrl: string | URL): Promise { const tokens = (await this.getProvider(serverName, serverUrl).tokens()) as | StoredMcpOAuthTokens @@ -208,13 +181,6 @@ export class McpOAuthService { ); } - /** - * Single-flight token refresh per credential: concurrent callers share one - * in-flight SDK `auth()` run, so two sessions expiring together cannot race - * a rotating refresh token. Resolves when the grant is usable again; - * rejects when the refresh token was rejected (or never existed) and an - * interactive login is required. - */ async refresh(serverName: string, serverUrl: string | URL): Promise { const storeKey = mcpOAuthStoreKey(serverName, serverUrl); const existing = this.refreshes.get(storeKey); @@ -229,13 +195,6 @@ export class McpOAuthService { return task; } - /** - * Arm the proactive refresh timer for every stored credential that carries - * enough data to expire. Called once at engine start; subsequent token - * writes re-arm through the provider save hook. A malformed meta sidecar - * (or any per-credential failure) is skipped with a warning rather than - * aborting the whole sweep. - */ async sweepProactiveRefresh(): Promise { if (this.shuttingDown) return; const keys = await this.store.list(); @@ -257,17 +216,11 @@ export class McpOAuthService { } } - /** Clear every pending proactive-refresh timer (engine shutdown, tests). */ stopProactiveRefresh(): void { for (const timer of this.refreshTimers.values()) timer.cancel(); this.refreshTimers.clear(); } - /** - * Release everything the service owns: pending proactive-refresh timers, - * in-flight refreshes and interactive flows (closing their callback - * listeners), event listeners, and cached providers. Idempotent. - */ shutdown(): Promise { if (this.shutdownPromise !== undefined) return this.shutdownPromise; this.shuttingDown = true; @@ -336,17 +289,6 @@ export class McpOAuthService { }) as typeof fetch; } - /** - * Drive the SDK `auth()` orchestrator far enough to surface an - * authorization URL. The caller is responsible for displaying the URL - * (typically via the synthetic authenticate tool) and then awaiting - * `complete()` to finish the code exchange. - * - * Interactive flows are serialized per credential: while one flow for a - * store key is in flight, further calls join it — same URL, shared - * `complete()`, and a `cancel()` that only detaches the caller — instead - * of resetting the shared provider's PKCE/state mid-flow. - */ async beginAuthorization( serverName: string, serverUrl: string | URL, @@ -550,11 +492,6 @@ export class McpOAuthService { }; } - /** - * Clear stored credentials for a server. Use `'all'` after the user - * explicitly signs out; use `'tokens'` to force a re-auth while keeping - * the registered DCR client. - */ invalidate( serverName: string, serverUrl: string | URL, @@ -563,11 +500,6 @@ export class McpOAuthService { return this.getProvider(serverName, serverUrl).clearCredentials(scope); } - /** - * Drop the cached provider for a credential. After an invalidation this - * guarantees the next `beginAuthorization` starts from a clean in-memory - * flow state (files are always re-read, so this is defensive). - */ forgetProvider(serverName: string, serverUrl: string | URL): void { this.providers.delete(mcpOAuthStoreKey(serverName, serverUrl)); } @@ -689,7 +621,6 @@ export class McpOAuthService { } } -/** Thrown by `beginAuthorization` when stored tokens already satisfy the server. */ export class AlreadyAuthorizedError extends Error2 { constructor(serverName: string) { super( diff --git a/packages/agent-core-v2/src/persistence/interface/queryStore.ts b/packages/agent-core-v2/src/persistence/interface/queryStore.ts index 745a23c7e..49b537ec5 100644 --- a/packages/agent-core-v2/src/persistence/interface/queryStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/queryStore.ts @@ -25,11 +25,6 @@ export type QueryFilter = { export interface IQuery { where(filter: QueryFilter): IQuery; - /** - * Restrict to records whose ordered column `column` falls inside `bounds`. - * The column must have been declared at write time (`put`/`batch` with - * `columns`). - */ whereColumn(column: string, bounds: ColumnBounds): IQuery; orderBy(field: string, dir?: SortDir): IQuery; limit(n: number): IQuery; @@ -73,7 +68,6 @@ export interface Checkpoint { readonly seq: number; } -/** Numeric range bounds over an ordered column; every bound is optional. */ export interface ColumnBounds { readonly gt?: number; readonly gte?: number; @@ -81,13 +75,6 @@ export interface ColumnBounds { readonly lte?: number; } -/** - * A bounded page over an ordered column: rows whose column value falls inside - * `bounds` (all bounds optional), filtered by `filter`, ordered by the column - * in `dir` (default `'asc'`), at most `limit` rows. Rows sharing a column - * value come back in a deterministic but engine-specific order; a caller that - * needs a total order re-sorts the (bounded) page itself. - */ export interface ColumnPageQuery { readonly column: string; readonly dir?: SortDir; @@ -108,19 +95,11 @@ export interface IQueryStore { batch(ops: readonly WriteOp[]): Promise; delete(collection: string, key: string): Promise; get(collection: string, key: string): Promise; - /** Point reads for several keys; missing keys are absent from the result. */ getMany(collection: string, keys: readonly string[]): Promise>; query(collection: string): IQuery; - /** - * Bounded page over an ordered column (see `ColumnPageQuery`). This is the - * keyset-pagination primitive: it must stay cheap even over large - * collections (index walk, not a full scan + in-memory sort). - */ pageByColumn(collection: string, query: ColumnPageQuery): Promise>; ensureIndex(collection: string, def: IndexDef): Promise; - /** Every key currently in the collection (engine key decoding applied). */ listKeys(collection: string): Promise; - /** Delete the whole collection; a no-op when it does not exist. */ dropCollection(collection: string): Promise; getCheckpoint(source: string): Promise; setCheckpoint(source: string, checkpoint: Checkpoint): Promise; diff --git a/packages/agent-core-v2/src/runtime/standaloneRuntime.ts b/packages/agent-core-v2/src/runtime/standaloneRuntime.ts index 018618e67..02b65a2bf 100644 --- a/packages/agent-core-v2/src/runtime/standaloneRuntime.ts +++ b/packages/agent-core-v2/src/runtime/standaloneRuntime.ts @@ -10,10 +10,6 @@ import { IHostTerminalService } from '#/os/interface/terminal'; import { LocalRuntime } from './localRuntime'; import type { Runtime } from './runtime'; -/** - * Builds fully detached local runtimes that belong to no workspace instance, - * for entry points that must touch the filesystem without materializing one. - */ export interface IStandaloneRuntimeFactory { readonly _serviceBrand: undefined; createLocalRuntime(workspaceId: string): Runtime; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index acf43f269..65dc724c8 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -57,28 +57,10 @@ export interface IAgentLifecycleService { broadcastPermissionMode(mode: PermissionMode): void; remove(agent: AgentContext): Promise; - /** - * Transitional bridge to the compatibility Agent scope (removed in M6): - * the scope handle for a live agent, or `undefined` when the agent is - * unknown or already closing. - */ handleOf(agentId: string): IAgentScopeHandle | undefined; - /** - * Transitional bridge for hosts that materialize the compatibility Agent - * scope out of band (removed in M6): registers an existing scope as a - * managed agent, applying the registered runtime definitions. Durable - * participants attach through `attachRuntimes` once the scope is fully - * materialized. Returns the scope's `AgentContext`. - */ adopt(handle: IAgentScopeHandle): AgentContext; - /** - * Transitional bridge (removed in M6): attaches the agent's durable - * runtime participants to its event dispatcher and, on the first call, - * marks the agent active and fires `onDidCreate` / `onDidCreateScope`. - * Must run before the dispatcher restores; idempotent. - */ attachRuntimes(agent: AgentContext): void; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 5d8518bd6..d7b11c489 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -45,13 +45,6 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise; setTitle(title: string): Promise; - /** - * Applies a generated title unless the user customized theirs; the title - * kind is re-checked inside the serialized update, right before the write, - * so a custom title set while a generation was in flight still wins. - * `force` skips the kind check entirely (explicit user-requested - * regeneration — last writer wins). - */ setGeneratedTitleIfUncustomized( title: string, opts?: { force?: boolean }, diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts index 9558cfdf2..540d89b71 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -1,32 +1,15 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** - * The first turn's excerpt: the opening natural-language user prompt and the - * final assistant text of that turn. Either side is `undefined` when the - * live window does not (yet) hold it — `first_turn` generation stays strict - * and reports unavailability instead of degrading. - */ export interface TitleTurnExcerpt { readonly user?: string | undefined; readonly assistant?: string | undefined; } -/** - * One turn of the whole-conversation digest: a natural-language user prompt - * paired with the final assistant text of its turn (`undefined` while that - * turn has not produced one). - */ export interface TitleDigestTurn { readonly user: string; readonly assistant?: string; } -/** - * The whole-conversation digest excerpt: every natural-language user prompt - * in the live window, each paired with its own turn's final assistant text, - * in chronological order. The window may be post-compaction — the digest - * covers whatever the window still holds. - */ export interface TitleDigestExcerpt { readonly turns: readonly TitleDigestTurn[]; } diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index d500df54b..9281487b1 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -1,16 +1,5 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** - * Which conversation excerpt a title generation draws from: - * - `user_prompts` (default): the first natural-language user prompts. - * - `first_turn`: the opening user prompt plus the first turn's final - * assistant text; strict — unavailable until the first turn has produced - * an assistant reply. - * - `digest`: the whole conversation arc — every natural-language user - * prompt in the live window paired with its own turn's final assistant - * text, using whatever the (possibly compacted) window still holds; - * meant for explicit regeneration on multi-turn sessions. - */ export type SessionTitleSource = 'user_prompts' | 'first_turn' | 'digest'; export interface ISessionTitleService { diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index c97b390b6..64a7bebb6 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -14,12 +14,6 @@ export class SessionTitleService implements ISessionTitleService { @IFlagService private readonly flags: IFlagService, ) {} - /** - * Always resolves to `undefined`. Titles were generated by a hosted endpoint - * that this product does not operate; that path is gone and nothing local has - * replaced it. The guards are kept so an existing custom or generated title is - * never disturbed, and the rename surface keeps working. - */ async generateTitle(opts?: { force?: boolean; source?: SessionTitleSource; diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts index 42dba732d..616fac527 100644 --- a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts @@ -27,15 +27,7 @@ export interface ISessionTokenCountingService { output: readonly Message[], usage: TokenUsage, ): void; - /** Tokens of the most recent measured anchor (0 when none) — a real reading - * that stays valid across transient uncascaded context rewrites. */ latestMeasured(agent: AgentContext): number; - /** The externally reported context size — the ONLY reading the - * `[token_counting]` strategy selects: `measured` reports the latest - * measured anchor alone, `estimated` reports a pure estimate with anchors - * ignored, and the default reports the live size floored by the last - * measured total. Internal logic (triggers, budgets, overflow backoff) - * must use `get()` / the estimate primitives, never this method. */ statusSize(agent: AgentContext): number; recordTruncation(agent: AgentContext, cutIndex: number): void; rebase(agent: AgentContext, input: TokenCountingRebaseInput): void; diff --git a/packages/agent-core-v2/src/state/agentModel.ts b/packages/agent-core-v2/src/state/agentModel.ts index ebdbdde65..fb51c3d87 100644 --- a/packages/agent-core-v2/src/state/agentModel.ts +++ b/packages/agent-core-v2/src/state/agentModel.ts @@ -32,13 +32,6 @@ interface ModelWindow { replacement: unknown; } -/** - * Base class of an agent-granular domain Model — the container of one - * domain's replayable state. Subclasses register appliers in the constructor - * via `this.on(EventClass, applier)`; the host runs each applier inside an - * infra-controlled immer window where `this.state` is the mutable draft. - * Outside the window `this.state` is the last committed frozen snapshot. - */ export abstract class AgentModel implements DomainResourceRuntime { private committedState: S; private window: ModelWindow | undefined; @@ -150,12 +143,6 @@ export interface AgentModelDefinitionInput> { const AGENT_MODEL_DEFINITIONS = new Map>(); -/** - * Declares one domain's agent-granular Model: the Model class, its state - * spec, and the static durable-event vocabulary its appliers cover. The - * returned definition is the token used with `AgentContext.space.use(...)` - * and `Feature.contributeAgentModel(...)`. - */ export function defineAgentModel>( input: AgentModelDefinitionInput, ): AgentModelDefinition { diff --git a/packages/agent-core-v2/src/tool/toolInputDisplay.ts b/packages/agent-core-v2/src/tool/toolInputDisplay.ts index 9ace28541..70263161b 100644 --- a/packages/agent-core-v2/src/tool/toolInputDisplay.ts +++ b/packages/agent-core-v2/src/tool/toolInputDisplay.ts @@ -1,8 +1,3 @@ -/** - * `ToolInputDisplay` — structured UI hint describing a tool call's input, so - * approval panels and tool renderers can present it without re-deriving it - * from raw arguments. - */ export type ToolInputDisplay = | { kind: 'command'; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts index 219825b6b..4ecad1040 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycle.ts @@ -13,12 +13,6 @@ export interface CreateSessionOptions { readonly workDir: string; readonly additionalDirs?: readonly string[]; readonly mainAgentBinding?: BindAgentInput; - /** - * Ephemeral per-session MCP servers: connected only for this session, - * visible only to this session (an entry shadows a workspace server of the - * same name), never persisted to any MCP config file, and released when - * the session closes. Not carried over by fork or resume. - */ readonly mcpServers?: Readonly>; } @@ -27,21 +21,11 @@ export interface ForkSessionOptions { readonly newSessionId?: string; readonly title?: string; readonly metadata?: Record; - /** - * Zero-based index of the user-visible turn to retain through. When omitted, - * the complete session is copied (the existing fork behavior). - */ readonly turnIndex?: number; } export interface ResumeSessionOptions { readonly additionalDirs?: readonly string[]; - /** - * Ephemeral per-session MCP servers — the same semantics as - * `CreateSessionOptions.mcpServers`: a session-owned overlay connected for - * this session only, never persisted, released when the session closes. - * Ignored when the session is already live (resume passes through). - */ readonly mcpServers?: Readonly>; } @@ -78,20 +62,6 @@ export interface SessionForkedEvent { readonly handle: ISessionScopeHandle; } -/** - * Participation surface of `onWillCreateSession` — the business-lifecycle - * moment "a session is being created", fired synchronously before the new - * session's services activate (the `will` half of `onDidCreateSession`; - * resume and fork are creations too). Workspace-scope participants step - * into the creation through the session domain's own vocabulary — read the - * session's seeded facts (`readSeed`), contribute or replace a session seed - * (`contributeSeed`; a seed already projected by the workspace seed - * adapters is replaced), and attach teardown work to the session's lifetime - * (`onSessionDispose` — runs with the session's teardown on every path: - * close, archive, delete, a failed create, workspace teardown). The event - * carries only facts the lifecycle itself owns; anything a participant - * needs beyond them travels as a session-domain seed. - */ export interface SessionWillCreateEvent { readonly sessionId: string; readSeed(id: ServiceIdentifier): T; diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts index 9ad497bc1..16a3fabf6 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts @@ -26,7 +26,6 @@ export interface IWorkspaceFsWatchSubscription extends IDisposable { readonly watchedPaths: readonly string[]; - /** Resolves when the active OS watcher is ready. Resolves immediately while no paths are watched. */ readonly ready: Promise; readonly onDidChangeFiles: Event; diff --git a/packages/agent-gateway/src/instanceRegistry.ts b/packages/agent-gateway/src/instanceRegistry.ts index 92497d6ff..23a1c2849 100644 --- a/packages/agent-gateway/src/instanceRegistry.ts +++ b/packages/agent-gateway/src/instanceRegistry.ts @@ -5,13 +5,11 @@ import { join } from 'node:path'; import { resolvePythinkerHome } from '@pymodel/agent-core-v2'; import { ulid } from 'ulid'; -/** Default cadence for refreshing `heartbeat_at`. */ export const HEARTBEAT_INTERVAL_MS = 15_000; export const DEFAULT_SERVER_DIR = join(resolvePythinkerHome(), 'server'); export const DEFAULT_SERVER_INSTANCES_DIR = join(DEFAULT_SERVER_DIR, 'instances'); -/** In-memory shape of a registered instance. camelCase for TS consumers. */ export interface ServerInstanceInfo { readonly serverId: string; readonly pid: number; @@ -34,30 +32,20 @@ interface ServerInstanceDisk { export interface InstanceRegistration { readonly serverId: string; - /** Rewrite this instance's file with a fresh heartbeat and, optionally, a new port. */ update(patch: { port?: number }): Promise; - /** Remove the instance file and stop heartbeating. Idempotent, best-effort on shutdown. */ release(): Promise; } export interface IInstanceRegistry { - /** - * Register this process. Sweeps stale (dead-pid) entries as a side effect, - * writes the instance file, and starts the heartbeat timer. - */ register( info: Omit, ): Promise; - /** List live instances; dead-pid entries are filtered and lazily removed. */ listLive(): Promise; } export interface InstanceRegistryOptions { - /** Directory holding `.json` files. Defaults to `/server/instances`. */ readonly instancesDir?: string; - /** Override `Date.now` — used in tests for deterministic timestamps. */ readonly now?: () => number; - /** Override the heartbeat cadence — used in tests to avoid a 15s wait. */ readonly heartbeatIntervalMs?: number; } @@ -279,25 +267,18 @@ export function createInstanceRegistry(options: InstanceRegistryOptions = {}): I }; } -/** Resolve the instances directory for a given home (or the default pythinker home). */ export function resolveServerInstancesDir(homeDir?: string): string { return homeDir === undefined ? DEFAULT_SERVER_INSTANCES_DIR : join(homeDir, 'server', 'instances'); } -/** Convenience one-shot read: list live instances under a home directory. */ export async function listLiveServerInstances( homeDir?: string, ): Promise { return createInstanceRegistry({ instancesDir: resolveServerInstancesDir(homeDir) }).listLive(); } -/** - * Convenience one-shot read: return the longest-running live instance, or - * `undefined` when none exist. For callers that only need a single daemon to - * talk to (e.g. the CLI's `server ps/kill` and the `pythinker web` spawner). - */ export async function getLiveServerInstance( homeDir?: string, ): Promise { diff --git a/packages/agent-gateway/src/lib/httpRange.ts b/packages/agent-gateway/src/lib/httpRange.ts index aa84b4256..68e312902 100644 --- a/packages/agent-gateway/src/lib/httpRange.ts +++ b/packages/agent-gateway/src/lib/httpRange.ts @@ -7,11 +7,6 @@ export function pickHeader( return Array.isArray(v) ? (v[0] as string | undefined) : (v as string); } -/** - * Parse a single-range `bytes=` header against a known size. Returns null for - * absent, malformed, multi-range, or unsatisfiable specs (callers then serve - * the full body with 200). - */ export function parseRangeHeader( raw: string | undefined, size: number, diff --git a/packages/agent-gateway/src/lib/promptMedia.ts b/packages/agent-gateway/src/lib/promptMedia.ts index bb2659eda..c1a4f8b1b 100644 --- a/packages/agent-gateway/src/lib/promptMedia.ts +++ b/packages/agent-gateway/src/lib/promptMedia.ts @@ -31,13 +31,6 @@ import { resolveStoragePath } from './storagePath'; type WireContent = PromptSubmission['content']; -/** - * Fail fast on stale or mis-kinded file references before anything - * session-scoped happens: a bad `file_id` (unknown, or a real file used with - * the wrong media kind, e.g. a PDF submitted as a video) must reject the - * request without creating the prompt agent and without touching the - * session's model/thinking/permission. - */ export async function assertPromptFileRefs(content: WireContent, store: IFileService): Promise { for (const part of content) { if (part.type === 'file') { @@ -49,11 +42,6 @@ export async function assertPromptFileRefs(content: WireContent, store: IFileSer } } -/** - * Fail fast on stale `session_media` references: a file id with no canonical - * copy in this session must reject the request before anything is resolved - * or mutated. - */ export async function assertPromptSessionMediaRefs( content: WireContent, store: ISessionMediaStore, @@ -83,42 +71,16 @@ export function contentToCoreParts(content: WireContent): ContentPart[] { } export interface ResolvePromptMediaOptions { - /** - * Lazily resolve the session's media-originals dir for persisting the - * pre-compression bytes of inline base64 images. Only invoked when an image - * was actually compressed; a failure or undefined result falls back to the - * shared temp-dir cache. - */ readonly resolveOriginalsDir?: () => Promise; - /** - * Lazily resolve the session's attachments dir for materializing arbitrary - * file uploads (and image bytes the provider rejects) into a path the model - * can open with the Read tool. A failure or undefined result falls back to - * the shared cache dir. - */ readonly resolveAttachmentsDir?: () => Promise; - /** Report an `image_compress` event per compressed prompt image. */ readonly telemetry?: ITelemetryService; } export interface PromptMediaPreparation { readonly content: WireContent; - /** - * Delete the transient daemon uploads this preparation created (the - * compressed re-save). Call on failure, or after the engine has either - * materialized the Session-owned copy or terminally rejected the prompt. - */ readonly discard: () => Promise; } -/** - * Resolve a wire content list's media/file references into their final wire - * form: uploaded files materialize to a session-local path notice, images are - * format-gated and compressed, and image/video uploads enter context as bare - * internal `pythinker-file://` references. The preparation's `content` is the - * input array unchanged when nothing needed resolving; `discard` rolls back - * or releases the daemon uploads the preparation created after intake. - */ export async function resolvePromptMediaFiles( input: WireContent, store: IFileService, diff --git a/packages/agent-gateway/src/lib/requestLog.ts b/packages/agent-gateway/src/lib/requestLog.ts index 0f67d7b03..80ace5757 100644 --- a/packages/agent-gateway/src/lib/requestLog.ts +++ b/packages/agent-gateway/src/lib/requestLog.ts @@ -1,13 +1,7 @@ import type { Logger } from 'pino'; -/** Minimal pino surface used by route handlers. */ export type RequestLogger = Pick; -/** - * Extract Fastify's per-request logger from a narrowed route-handler request. - * Returns `undefined` only for hand-rolled test doubles that never install a - * logger — callers should use optional chaining. - */ export function requestLog(req: { id: string }): RequestLogger | undefined { return (req as { log?: RequestLogger }).log; } diff --git a/packages/agent-gateway/src/middleware/auth.ts b/packages/agent-gateway/src/middleware/auth.ts index 9fedc57a6..0bb9ea42f 100644 --- a/packages/agent-gateway/src/middleware/auth.ts +++ b/packages/agent-gateway/src/middleware/auth.ts @@ -17,11 +17,6 @@ const BEARER_PREFIX = 'Bearer '; export interface AuthHookOptions { readonly isBypassed?: (req: FastifyRequest) => boolean; readonly limiter?: Pick; - /** - * Unified credential validator. Defaults to `authTokenService.isValid` - * (persistent token / password). `start.ts` supplies one that also accepts - * the optional `rpcToken` so the same credential gates every surface. - */ readonly validateCredential?: CredentialValidator; } diff --git a/packages/agent-gateway/src/middleware/defineRoute.ts b/packages/agent-gateway/src/middleware/defineRoute.ts index de8582e90..a47f67ad3 100644 --- a/packages/agent-gateway/src/middleware/defineRoute.ts +++ b/packages/agent-gateway/src/middleware/defineRoute.ts @@ -86,40 +86,18 @@ export interface DefineRouteOptions< TQuery extends z.ZodTypeAny | undefined, TSuccessData extends z.ZodTypeAny | undefined, > { - /** HTTP method (used by the consumer for registration bookkeeping). */ method: string; - /** Route path with OpenAPI `{param}` syntax. */ path: string; - /** Request-body Zod schema. */ body?: TBody; - /** Route-params Zod schema. */ params?: TParams; - /** Query-string Zod schema. */ querystring?: TQuery; - /** Success payload schema (wrapped in envelope code:0 automatically). */ success?: { data: TSuccessData }; - /** - * Error variants for the 200-response oneOf. - * Key = business error code. - * `dataSchema` defaults to `z.null()`; override for idempotent-conflict - * shapes such as `{code:40903, data:{aborted:false}}`. - * `detailsSchema` describes the structured error context when present. - */ errors?: Record; - /** - * Raw response schemas that are NOT envelope-wrapped. - * Useful for binary-stream endpoints (e.g. file download). - */ rawResponse?: Record>; - /** Swagger description. */ description?: string; - /** Swagger summary. */ summary?: string; - /** Swagger tags. */ tags?: string[]; - /** Swagger operationId. */ operationId?: string; - /** Swagger consumes. */ consumes?: string[]; } @@ -129,7 +107,6 @@ export interface RouteDefinition< TQuery extends z.ZodTypeAny | undefined, > { method: string; - /** Fastify-style path (`:param`). */ path: string; options: { preHandler: unknown[]; @@ -146,17 +123,6 @@ export interface RouteDefinition< ) => Promise | void; } -/** - * Declare a route from a single Zod-based definition. - * - * Returns a `RouteDefinition` carrying: - * - `path` – converted to Fastify `:param` syntax - * - `options` – `preHandler` (runtime validation) + `schema` (Swagger) - * - `handler` – typed request / reply callback - * - * The caller is responsible for registering the definition on the correct - * app verb, e.g. `app.post(route.path, route.options, route.handler)`. - */ export function defineRoute< TBody extends z.ZodTypeAny | undefined, TParams extends z.ZodTypeAny | undefined, diff --git a/packages/agent-gateway/src/middleware/hostnames.ts b/packages/agent-gateway/src/middleware/hostnames.ts index d21c89f39..3ed37d492 100644 --- a/packages/agent-gateway/src/middleware/hostnames.ts +++ b/packages/agent-gateway/src/middleware/hostnames.ts @@ -7,28 +7,16 @@ import { errEnvelope } from '../envelope'; const HOST_ERROR_CODE = 40301; export interface HostCheckOptions { - /** The host the server bound to; always allowed (port stripped both sides). */ readonly boundHost?: string; - /** Extra allowed hosts / domain-suffix patterns (from `PYTHINKER_CODE_ALLOWED_HOSTS`). */ readonly extra?: readonly string[]; - /** Disable the check entirely (`PYTHINKER_CODE_DISABLE_HOST_CHECK=1`; test-only). */ readonly disable?: boolean; } -/** Returned by {@link createHostCheck}: the Fastify hook plus the raw predicate. */ export interface HostCheck { - /** Fastify `onRequest` hook that 403s on a disallowed `Host`. */ readonly onRequest: (req: FastifyRequest, reply: FastifyReply) => Promise; - /** Reusable predicate (also used by the WS upgrade path in M4.3). */ readonly isAllowed: (host: string | undefined) => boolean; } -/** - * Parse `PYTHINKER_CODE_ALLOWED_HOSTS` into an `extra` allowlist. - * - * Comma-separated, trimmed, empties dropped. A leading `.` is preserved so the - * caller can express domain-suffix wildcards (`.example.com`). - */ export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[] { const raw = env['PYTHINKER_CODE_ALLOWED_HOSTS']; if (raw === undefined) { @@ -40,21 +28,10 @@ export function parseAllowedHosts(env: NodeJS.ProcessEnv = process.env): string[ .filter((entry) => entry.length > 0); } -/** True when `PYTHINKER_CODE_DISABLE_HOST_CHECK=1` (test/controlled env only). */ export function isHostCheckDisabled(env: NodeJS.ProcessEnv = process.env): boolean { return env['PYTHINKER_CODE_DISABLE_HOST_CHECK'] === '1'; } -/** - * Strip a trailing `:port` from a `Host` value and lowercase it. - * - * Handles: - * - bracketed IPv6 with a port: `[::1]:80` → `[::1]`; - * - host/IPv4 with a port: `localhost:80` → `localhost`, `1.2.3.4:5678` → `1.2.3.4`; - * - bare values (no port): returned lowercased as-is; - * - bare IPv6 without brackets (multiple colons, e.g. `::1`): returned - * lowercased as-is — there is no unambiguous port to strip. - */ export function stripPort(host: string): string { if (host.startsWith('[')) { const end = host.indexOf(']'); @@ -81,12 +58,6 @@ export function formatHostErrorMessage(host: string | undefined): string { return `Invalid Host header: ${hostLabel}; allow this host with PYTHINKER_CODE_ALLOWED_HOSTS=${hostArg} or 'pythinker web --allowed-host ${hostArg}'.`; } -/** - * Decide whether a `Host` value is allowed under the given options. - * - * Missing/empty `Host` is rejected (HTTP/1.1 requires it). The check is a no-op - * when `opts.disable` is set. - */ export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): boolean { if (opts.disable === true) { return true; @@ -123,12 +94,6 @@ export function isAllowedHost(host: string | undefined, opts: HostCheckOptions): return false; } -/** - * Build the Fastify `onRequest` hook and the reusable `isAllowed` predicate. - * - * Returning the `reply` from the hook short-circuits Fastify on 403 so the - * route handler never runs. - */ export function createHostCheck(opts: HostCheckOptions): HostCheck { const isAllowed = (host: string | undefined): boolean => isAllowedHost(host, opts); const onRequest = async ( diff --git a/packages/agent-gateway/src/middleware/origin.ts b/packages/agent-gateway/src/middleware/origin.ts index 8ef340402..e44d38387 100644 --- a/packages/agent-gateway/src/middleware/origin.ts +++ b/packages/agent-gateway/src/middleware/origin.ts @@ -6,16 +6,9 @@ const CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; const CORS_ALLOW_HEADERS = 'Content-Type, Authorization, X-Pythinker-Client-Id, X-Pythinker-Client-Name, X-Pythinker-Client-Version, X-Pythinker-Client-Ui-Mode'; export interface OriginHookOptions { - /** Explicit cross-origin allowlist (full origin strings, scheme + host). */ readonly allowedOrigins?: readonly string[]; } -/** - * Parse `PYTHINKER_CODE_CORS_ORIGINS` into an allowlist. - * - * Comma-separated, trimmed, empties dropped. No `*` wildcard — every entry is - * an explicit origin (PLAN §3.4). - */ export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] { const raw = env['PYTHINKER_CODE_CORS_ORIGINS']; if (raw === undefined) { @@ -27,10 +20,6 @@ export function parseCorsOrigins(env: NodeJS.ProcessEnv = process.env): string[] .filter((entry) => entry.length > 0); } -/** - * Return the `host` (host[:port], default port dropped) of an `Origin` value, - * or `undefined` when the origin is missing or malformed. - */ export function originHost(origin: string | undefined): string | undefined { if (origin === undefined) { return undefined; @@ -42,13 +31,6 @@ export function originHost(origin: string | undefined): string | undefined { } } -/** - * Decide whether an `Origin` is allowed for a request to `host`. - * - * - missing/malformed `Origin` → allowed (non-CORS / non-browser client); - * - same-origin (`Origin` host === `Host`, port stripped both sides) → allowed; - * - otherwise → allowed only when the full origin string is in `allowed`. - */ export function isOriginAllowed( origin: string | undefined, host: string | undefined, @@ -81,18 +63,6 @@ function isLoopbackHost(h: string): boolean { ); } -/** - * Build the Fastify `onRequest` CORS hook. - * - * Allowed origins get `Access-Control-Allow-Origin/-Methods` echoed and - * `Access-Control-Allow-Headers` reflected from the preflight's - * `Access-Control-Request-Headers` (falling back to `CORS_ALLOW_HEADERS` for - * non-preflight responses), so newly added client request headers do not - * require a matching server-side allowlist change; `OPTIONS` preflights - * short-circuit to `204`. Disallowed origins get no CORS headers (the browser - * blocks the response); their `OPTIONS` preflight still returns `204` so it - * fails closed without leaking headers. - */ export function createOriginHook( opts: OriginHookOptions, ): (req: FastifyRequest, reply: FastifyReply) => Promise { diff --git a/packages/agent-gateway/src/middleware/rateLimit.ts b/packages/agent-gateway/src/middleware/rateLimit.ts index 088bc80ba..53109755e 100644 --- a/packages/agent-gateway/src/middleware/rateLimit.ts +++ b/packages/agent-gateway/src/middleware/rateLimit.ts @@ -3,23 +3,15 @@ export const AUTH_RATE_LIMIT_ERROR_NAME = 'PYTHINKER_AUTH_RATE_LIMIT'; export const AUTH_RATE_LIMIT_MSG = 'Too many failed auth attempts'; export interface AuthFailureLimiterOptions { - /** Failures within {@link windowMs} that trigger a ban. Default `10`. */ readonly maxFailures?: number; - /** Rolling failure window in ms. Default `60_000`. */ readonly windowMs?: number; - /** Ban duration in ms once the threshold is hit. Default `60_000`. */ readonly banMs?: number; - /** Emits a warn line when a source crosses into a ban. */ readonly logger?: { warn(obj: unknown, msg: string): void }; } -/** Minimal surface consumed by `createAuthHook`. */ export interface AuthFailureLimiter { - /** Record one failed auth attempt for `ip`. */ recordFailure(ip: string): void; - /** True while `ip` is inside an active ban window. */ isBanned(ip: string): boolean; - /** Stop the periodic cleanup timer and drop all state (shutdown / tests). */ dispose(): void; } @@ -33,13 +25,6 @@ const DEFAULT_MAX_FAILURES = 10; const DEFAULT_WINDOW_MS = 60_000; const DEFAULT_BAN_MS = 60_000; -/** - * Build a per-source auth-failure limiter. - * - * A periodic sweep drops entries that are neither banned nor within an active - * failure window so the map does not grow without bound on a long-lived - * public server. The timer is `unref`-ed so it never keeps the process alive. - */ export function createAuthFailureLimiter( opts?: AuthFailureLimiterOptions, ): AuthFailureLimiter { diff --git a/packages/agent-gateway/src/middleware/schema.ts b/packages/agent-gateway/src/middleware/schema.ts index 9af8560f8..7c2789476 100644 --- a/packages/agent-gateway/src/middleware/schema.ts +++ b/packages/agent-gateway/src/middleware/schema.ts @@ -1,32 +1,14 @@ import { envelopeSchema } from '../protocol/envelope'; import { z } from 'zod'; -/** - * Convert a Zod schema to a plain JSON Schema object suitable for - * Fastify's `schema` option. - * - * We drop the top-level `$schema` key because Fastify/OpenAPI inline - * schemas don't need it. - */ export function jsonSchema(schema: z.ZodTypeAny): Record { return jsonSchemaForTarget(schema, 'input', 'draft-7'); } -/** - * Convert a Zod schema to a response-side Fastify JSON Schema object. - */ export function outputJsonSchema(schema: z.ZodTypeAny): Record { return jsonSchemaForTarget(schema, 'output', 'draft-7'); } -/** - * Convert a Zod schema directly to an OpenAPI 3 schema object. - * - * Fastify route schemas use draft-7 because Fastify validates/serializes with - * AJV; `@fastify/swagger` converts those schemas to OpenAPI. Post-processing - * hooks run after that conversion, so schemas inserted there must already use - * OpenAPI 3 semantics. - */ export function openApiDocumentJsonSchema( schema: z.ZodTypeAny, io: 'input' | 'output' = 'input', @@ -50,10 +32,6 @@ function jsonSchemaForTarget( return converted; } -/** - * Wrap a data Zod schema in the server's envelope shape and return its - * JSON Schema representation. - */ export function envelopeJsonSchema( dataSchema: z.ZodTypeAny, ): Record { @@ -66,31 +44,11 @@ export function openApiDocumentEnvelopeJsonSchema( return openApiDocumentJsonSchema(envelopeSchema(dataSchema), 'output'); } -/** - * Build a Fastify route-schema bag from Zod schemas + metadata. - * - * All Zod fields are automatically converted via `jsonSchema()`. - * The `response` map values are also wrapped in envelopes unless you - * pass an explicit `rawResponse` option. - */ export interface RouteSchemaOptions { - /** Request body Zod schema. */ body?: z.ZodTypeAny; - /** Query-string Zod schema. */ querystring?: z.ZodTypeAny; - /** Route params Zod schema. */ params?: z.ZodTypeAny; - /** - * Response schema map: status code → Zod schema. - * Each schema is automatically wrapped in the envelope. - * Use `rawResponse` if you need an unwrapped schema (e.g. binary - * download success path). - */ response?: Record; - /** - * Response schema map that is NOT envelope-wrapped. - * Useful for the `200` on binary-stream endpoints. - */ rawResponse?: Record>; description?: string; summary?: string; diff --git a/packages/agent-gateway/src/middleware/securityHeaders.ts b/packages/agent-gateway/src/middleware/securityHeaders.ts index ce2a56a2d..02a93442e 100644 --- a/packages/agent-gateway/src/middleware/securityHeaders.ts +++ b/packages/agent-gateway/src/middleware/securityHeaders.ts @@ -1,7 +1,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; export interface SecurityHeadersOptions { - /** When true, also emit `Strict-Transport-Security`. */ readonly tls: boolean; } @@ -9,10 +8,6 @@ const HSTS_VALUE = 'max-age=31536000'; const CONTENT_SECURITY_POLICY = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'"; -/** - * Build the `onSend` hook. Returns the payload unchanged so Fastify continues - * the response pipeline with the headers applied. - */ export function createSecurityHeadersHook( opts: SecurityHeadersOptions, ): (req: FastifyRequest, reply: FastifyReply, payload: unknown) => Promise { diff --git a/packages/agent-gateway/src/middleware/validate.ts b/packages/agent-gateway/src/middleware/validate.ts index 213056c83..65aa32984 100644 --- a/packages/agent-gateway/src/middleware/validate.ts +++ b/packages/agent-gateway/src/middleware/validate.ts @@ -55,10 +55,6 @@ function buildValidationEnvelope( }; } -/** - * Build a Fastify `preHandler` that parses `req.body` against `schema`. - * On success, replaces `req.body` with the parsed value. - */ export function validateBody(schema: z.ZodType): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.body); @@ -71,14 +67,6 @@ export function validateBody(schema: z.ZodType): PreHandlerHook { }; } -/** - * Build a Fastify `preHandler` that parses `req.query` against `schema`. - * On success, replaces `req.query` with the parsed value. - * - * Fastify deserializes query strings as `Record` — so numeric - * fields arrive as strings. The schema is responsible for coercing - * (`z.coerce.number()` etc.) when needed; we don't pre-coerce here. - */ export function validateQuery(schema: z.ZodType): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.query); @@ -91,9 +79,6 @@ export function validateQuery(schema: z.ZodType): PreHandlerHook { }; } -/** - * Build a Fastify `preHandler` that parses `req.params` against `schema`. - */ export function validateParams(schema: z.ZodType): PreHandlerHook { return (req, reply, done) => { const result = schema.safeParse(req.params); diff --git a/packages/agent-gateway/src/protocol/envelope.ts b/packages/agent-gateway/src/protocol/envelope.ts index 581087ddd..d7eb3e8e1 100644 --- a/packages/agent-gateway/src/protocol/envelope.ts +++ b/packages/agent-gateway/src/protocol/envelope.ts @@ -23,13 +23,6 @@ export function okEnvelope(data: T, requestId: string): Envelope { return { code: 0, msg: 'success', data, request_id: requestId }; } -/** - * Build an error envelope. When `stack` is provided it is surfaced verbatim on - * the wire so operators can see where a thrown error originated; when omitted - * (or `undefined`) the field is absent and the wire shape stays byte-identical - * to the original `{ code, msg, data: null, request_id }` — `JSON.stringify` - * drops `undefined` properties, so callers that have no stack are unaffected. - */ export function errEnvelope( code: number, msg: string, diff --git a/packages/agent-gateway/src/protocol/error-codes.ts b/packages/agent-gateway/src/protocol/error-codes.ts index 0466106af..f70d3caa0 100644 --- a/packages/agent-gateway/src/protocol/error-codes.ts +++ b/packages/agent-gateway/src/protocol/error-codes.ts @@ -85,16 +85,5 @@ export const ErrorCode = { } as const; -/** - * Reserved (intentionally unallocated; do NOT reuse for new variants): - * - 40101 auth.invalid_token (daemon's own token; future) - * - 40102 auth.missing_token (daemon's own token; future) - * - 40103 auth.forbidden_origin (daemon's own token; future) - * - 42901 rate.limited - * - 50002 protocol.version_mismatch - * - * `ErrorCodeReason` is not copied here because the server has no consumer. - * The protocol package retains number-to-reason mapping for v1 and server E2E. - */ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/packages/agent-gateway/src/protocol/rest-modelCatalog.ts b/packages/agent-gateway/src/protocol/rest-modelCatalog.ts index 87a74dfa7..d3591bf17 100644 --- a/packages/agent-gateway/src/protocol/rest-modelCatalog.ts +++ b/packages/agent-gateway/src/protocol/rest-modelCatalog.ts @@ -21,10 +21,6 @@ export const getProviderResponseSchema = providerCatalogItemSchema.extend({ }); export type GetProviderResponse = z.infer; -/** - * The six wire protocols the core config schema accepts as a provider `type`. - * (`vertexai` resolves through the google-genai base's vertex mode at runtime.) - */ export const providerWireTypeSchema = z.enum([ 'pythinker', 'openai', @@ -71,7 +67,6 @@ function refineProviderForm( } } -/** The provider id shape accepted by the create/replace routes. */ export const providerIdSchema = z .string() .regex( @@ -106,14 +101,6 @@ export type CreateProviderRequest = z.infer; export const createProviderResponseSchema = providerCatalogItemSchema; export type CreateProviderResponse = z.infer; -/** - * The desktop "edit & save" payload: the whole provider form. `new_id` - * renames the provider (the id in the path is the current identity) — the - * providers key, all model aliases, default_provider and a default_model - * pointing at an old alias are migrated to the new id. `api_key` is - * tri-state so the edit form can leave the stored key untouched — absent - * keeps it, `""` clears it, anything else replaces it. - */ export const replaceProviderRequestSchema = z .object({ new_id: providerIdSchema.optional(), @@ -143,7 +130,6 @@ export const replaceProviderResponseSchema = z.object({ }); export type ReplaceProviderResponse = z.infer; -/** Pruned catalog model shape — enough for the import preview, nothing more. */ export const catalogModelItemSchema = z.object({ id: z.string().min(1), name: z.string().optional(), @@ -153,11 +139,6 @@ export const catalogModelItemSchema = z.object({ }); export type CatalogModelItem = z.infer; -/** - * One browsable models.dev entry. `rejected: true` means this client version - * cannot import it at all (greyed out, `reject_reason` explains); - * `needs_base_url: true` means the import form must collect a base URL. - */ export const catalogProviderItemSchema = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -179,19 +160,6 @@ export type ListCatalogProvidersResponse = z.infer; -/** - * Body of the `/providers:action` collection route. Every field is optional - * so the bodyless `:refresh` actions (and their legacy `{}` bodies) still - * validate; the `:import_catalog` handler enforces `catalog_id` and the - * `:import_registry` handler enforces `url` themselves. - * - * `:import_catalog` semantics: import a models.dev entry as a configured - * provider; `id` overrides the catalog id as the local provider id, and - * importing an id that already exists is a refresh (the provider and its - * aliases are rewritten from the catalog — the same re-import semantics as - * the TUI). The global default_provider/default_model pointers are never - * modified. - */ export const providerCollectionActionBodySchema = z.object({ catalog_id: z.string().min(1).optional(), api_key: z.string().optional(), @@ -207,13 +175,6 @@ export const importCatalogProviderResponseSchema = z.object({ }); export type ImportCatalogProviderResponse = z.infer; -/** - * Import a models.dev-shaped private registry (api.json URL + optional Bearer - * key) as configured providers. Re-import semantics: providers previously - * imported from the same URL but no longer listed are removed (the URL is the - * stable registry identity; the key commonly rotates). The global - * default_provider/default_model pointers are never modified. - */ export const importCustomRegistryResponseSchema = z.object({ providers: z.array(providerCatalogItemSchema), models_imported: z.number().int().min(0), diff --git a/packages/agent-gateway/src/protocol/rest-plugin.ts b/packages/agent-gateway/src/protocol/rest-plugin.ts index c6ff5bd9d..ee00138f4 100644 --- a/packages/agent-gateway/src/protocol/rest-plugin.ts +++ b/packages/agent-gateway/src/protocol/rest-plugin.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -/** GitHub provenance for github-sourced plugins (domain PluginGithubMetadata). */ export const pluginGithubMetadataSchema = z.object({ owner: z.string(), repo: z.string(), diff --git a/packages/agent-gateway/src/protocol/rest-session.ts b/packages/agent-gateway/src/protocol/rest-session.ts index 63b9b0532..074440420 100644 --- a/packages/agent-gateway/src/protocol/rest-session.ts +++ b/packages/agent-gateway/src/protocol/rest-session.ts @@ -162,9 +162,7 @@ export type ArchiveSessionResponse = z.infer; -/** @deprecated kept as an alias for backward compatibility; prefer archiveSessionResponseSchema. */ export const deleteSessionResponseSchema = archiveSessionResponseSchema; -/** @deprecated kept as an alias for backward compatibility; prefer ArchiveSessionResponse. */ export type DeleteSessionResponse = ArchiveSessionResponse; export const sessionAbortResponseSchema = z.object({ diff --git a/packages/agent-gateway/src/protocol/rest-skill.ts b/packages/agent-gateway/src/protocol/rest-skill.ts index 8a063e8fb..fa940e60f 100644 --- a/packages/agent-gateway/src/protocol/rest-skill.ts +++ b/packages/agent-gateway/src/protocol/rest-skill.ts @@ -8,10 +8,6 @@ export const listSkillsResponseSchema = z.object({ }); export type ListSkillsResponse = z.infer; -/** - * Attachment parts accepted on skill activation — the media/file subset of - * the prompt submission's `MessageContent` (text stays in `args`). - */ export const activateSkillAttachmentSchema = z.discriminatedUnion('type', [ imageContentSchema, videoContentSchema, diff --git a/packages/agent-gateway/src/protocol/rest-snapshot.ts b/packages/agent-gateway/src/protocol/rest-snapshot.ts index 055953ed0..cfa1ca2f7 100644 --- a/packages/agent-gateway/src/protocol/rest-snapshot.ts +++ b/packages/agent-gateway/src/protocol/rest-snapshot.ts @@ -32,11 +32,6 @@ export const inFlightTurnSchema = z.object({ }); export type InFlightTurn = z.infer; -/** - * A live subagent task as of the snapshot watermark. Extends the base task - * wire shape with the dynamic_workflow identity metadata that otherwise only rides the - * (non-replayed) `subagent.spawned` WS event. - */ export const snapshotSubagentSchema = taskSchema.extend({ subagent_phase: z.enum(['queued', 'working', 'suspended', 'completed', 'failed']).optional(), subagent_type: z.string().optional(), diff --git a/packages/agent-gateway/src/protocol/ws-control.ts b/packages/agent-gateway/src/protocol/ws-control.ts index eda980947..3aa08d050 100644 --- a/packages/agent-gateway/src/protocol/ws-control.ts +++ b/packages/agent-gateway/src/protocol/ws-control.ts @@ -5,22 +5,8 @@ import { transcriptGradeSpecSchema, transcriptSeqSchema } from '@pymodel/transcr import { eventSchema } from './events-zod'; -/** - * WS protocol version. v2 (breaking, IM-style multi-device sync): - * - per-session cursors are `{ seq, epoch }` instead of a bare seq - * - `seq` is durable (journal offset, survives daemon restarts) - * - volatile events carry `volatile: true` and do not advance `seq` - * - `resync_required` gains the `epoch_changed` reason + `epoch` field - */ export const WS_PROTOCOL_VERSION = 2; -/** - * Per-session sync cursor. `seq` is the last durable event seq the client - * has applied (journal offset). `epoch` identifies the journal incarnation - * (changes when a session's journal is recreated); a cursor whose epoch does - * not match the server's current epoch is invalid and triggers - * `resync_required(epoch_changed)`. `epoch` is absent on a fresh cursor. - */ export const sessionCursorSchema = z.object({ seq: z.number().int().nonnegative(), epoch: z.string().min(1).optional(), @@ -79,25 +65,10 @@ export const serverHelloMessageSchema = z.object({ export type ServerHelloMessage = z.infer; -/** - * Per-session agent allowlist for fine-grained v1 event subscriptions. Keys are - * session ids, values are the non-empty set of agent ids the client wants to - * receive events for within that session. Sessions absent from the map (or the - * whole field omitted) fall back to receiving every agent — the legacy - * session-grained behavior. - */ export const agentFilterSchema = z.record(z.string(), z.array(z.string()).min(1)); export type AgentFilter = z.infer; -/** - * `client_hello` is the handshake: only `client_id` is required. The - * subscription fields below are legacy compatibility — new clients send just - * `client_id` here and use `subscribe` frames (which carry the same - * per-session cursors / agent allowlist). - * @deprecated Inline subscriptions on `client_hello` are kept for older - * clients; prefer `subscribe`. - */ export const clientHelloPayloadSchema = z.object({ client_id: z.string(), subscriptions: z.array(z.string()).optional(), @@ -143,12 +114,6 @@ export const subscribeMessageSchema = z.object({ export type SubscribeMessage = z.infer; -/** - * `subscribe_v2` — the transcript subscription channel. Owns ONLY the - * per-agent transcript grades (and the optional op-batch seq cursor) for one - * session; legacy event subscription stays on `client_hello` / `subscribe`. - * The grade/seq schemas are owned by `@pymodel/transcript`. - */ export const subscribeV2PayloadSchema = z.object({ session_id: z.string().min(1), transcript: transcriptGradeSpecSchema, @@ -163,11 +128,6 @@ export const subscribeV2MessageSchema = z.object({ export type SubscribeV2Message = z.infer; -/** - * `unsubscribe_v2` — the agent-grained counterpart of `subscribe_v2`: - * detaches the listed agents' transcript streams (`agent_ids` absent = the - * whole session's stream) without touching the legacy event subscription. - */ export const unsubscribeV2PayloadSchema = z.object({ session_id: z.string().min(1), agent_ids: z.array(z.string().min(1)).min(1).optional(), @@ -246,11 +206,6 @@ export const watchFsAckPayloadSchema = z.object({ export const watchFsAckMessageSchema = wsAckEnvelopeSchema(watchFsAckPayloadSchema); -/** - * Filesystem change-notification payloads, ported verbatim from the v1 - * protocol's `fs.ts` (agent-core-v2 only carries the plain types). Emitted by - * the `watch_fs` notification path on subscribed sessions. - */ export const fsChangeKindSchema = z.enum(['file', 'directory', 'symlink']); export type FsChangeKind = z.infer; diff --git a/packages/agent-gateway/src/requestLogging.ts b/packages/agent-gateway/src/requestLogging.ts index dd89d0d5d..00aa85617 100644 --- a/packages/agent-gateway/src/requestLogging.ts +++ b/packages/agent-gateway/src/requestLogging.ts @@ -1,12 +1,5 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; -/** - * Pull the envelope `code` out of a serialized JSON response body. - * - * Returns `undefined` for non-string payloads (buffers, streams) and for bodies - * that are not envelopes (e.g. `/openapi.json`, static assets) — those simply - * log without a `code` field. - */ export function extractEnvelopeCode(payload: unknown): number | undefined { if (typeof payload !== 'string') { return undefined; @@ -19,13 +12,6 @@ export function extractEnvelopeCode(payload: unknown): number | undefined { return Number.isSafeInteger(code) ? code : undefined; } -/** - * Register the `onSend` + `onResponse` hooks that emit the access log line. - * - * The `onResponse` line mirrors Fastify's default shape (reqId via the child - * logger, `req`, `responseTime`, `msg: 'request completed'`) but swaps - * `res.statusCode` for the envelope `code`. - */ export function registerRequestLogging(app: FastifyInstance): void { const codes = new WeakMap(); diff --git a/packages/agent-gateway/src/routes/action-dispatch.ts b/packages/agent-gateway/src/routes/action-dispatch.ts index e1907d2a1..f267340cf 100644 --- a/packages/agent-gateway/src/routes/action-dispatch.ts +++ b/packages/agent-gateway/src/routes/action-dispatch.ts @@ -17,11 +17,6 @@ export function actionNames( return Object.keys(actions) as unknown as readonly TAction[]; } -/** - * Parse an `{id}:{action}` path tail against the table's action names. - * Returns the resolved target, or `{ message }` for the validation-failure - * response when the tail is not a known action. - */ export function resolveActionTarget(opts: { readonly tail: string; readonly actions: ActionTable; @@ -40,11 +35,6 @@ export function resolveActionTarget(opts: { return { id: parsed.id, action: parsed.action }; } -/** - * Invoke the table entry for `action`, validating the raw body against the - * entry's schema first when it declares one. Returns false when no entry - * matches, leaving the unsupported-action response to the caller. - */ export async function runAction(opts: { readonly action: string; readonly id: string; @@ -61,12 +51,6 @@ export async function runAction(opts: { return true; } -/** - * Resolve the tail and invoke the matching handler in one step, for routes - * whose parse guard needs no site-specific logic. `onUnsupported` produces - * the route's validation-failure response; the return value reports whether - * a handler ran. - */ export async function dispatchAction(opts: { readonly tail: string; readonly actions: ActionTable; diff --git a/packages/agent-gateway/src/routes/action-suffix.ts b/packages/agent-gateway/src/routes/action-suffix.ts index 2bad43a87..19558644a 100644 --- a/packages/agent-gateway/src/routes/action-suffix.ts +++ b/packages/agent-gateway/src/routes/action-suffix.ts @@ -6,17 +6,7 @@ export type ActionSuffixParse = export interface ParseActionSuffixOptions { readonly tail: string; readonly allowedActions: readonly TAction[]; - /** - * When set, a bare `` (no action suffix) is accepted and reported as - * `{kind:'bare'}`. When `undefined`, bare ids are rejected with - * `unsupported action: ` — appropriate for resources where every - * REST action is an explicit `:verb` (e.g. `/sessions/{sid}/prompts/`). - */ readonly defaultAction?: TAction; - /** - * Resource label used in the error message for empty-id failures, e.g. - * `'question'` → `"invalid question_id in path"`. Defaults to `'resource'`. - */ readonly resourceLabel?: string; } diff --git a/packages/agent-gateway/src/routes/fs.ts b/packages/agent-gateway/src/routes/fs.ts index 993fa9bb5..f0b8307e7 100644 --- a/packages/agent-gateway/src/routes/fs.ts +++ b/packages/agent-gateway/src/routes/fs.ts @@ -61,16 +61,6 @@ import { fsRevealRequestSchema, } from '../protocol/rest-fs'; -/** - * Body cap for the fs action route. `WorkspaceFsService.write` accepts 10 MiB - * of decoded content. Two encodings expand it: base64 costs ~4/3, and a UTF-8 - * string of control characters costs 6 bytes per character once JSON-escaped, - * so a legal 10 MiB write can serialize to ~60 MiB. A limit below that would - * reject such a request as a transport error before the route could run and - * answer `FS_TOO_LARGE` for content that genuinely is too large. Scoped - * to this route so the raise does not widen the request surface of every other - * endpoint. - */ export const FS_ACTION_BODY_LIMIT_BYTES = 64 * 1024 * 1024; interface FsRouteHost { diff --git a/packages/agent-gateway/src/routes/meta.ts b/packages/agent-gateway/src/routes/meta.ts index d8e618338..29ec70842 100644 --- a/packages/agent-gateway/src/routes/meta.ts +++ b/packages/agent-gateway/src/routes/meta.ts @@ -18,29 +18,9 @@ export interface MetaRouteOptions { readonly serverVersion: string; readonly serverId: string; readonly startedAt: string; - /** - * Whether the server was started with `--dangerous-bypass-auth`. Surfaced so - * the web UI can skip the token prompt and connect without a credential. - */ readonly dangerousBypassAuth: boolean; - /** - * Custom browser tab title for this instance (the CLI's `--web-title`). - * Surfaced as `web_title` in the `/meta` payload; instance-level and frozen - * at boot, so it joins the frozen static fields. Omitted when unset. - */ readonly webTitle?: string; - /** - * Resolves the effective experimental-flag map (flag id → enabled) at - * request time. Backed by `IFlagService.snapshot()` in production; tests may - * stub it. May return a promise — the handler awaits it, so flag state - * always reflects the fully loaded config (never pre-load defaults). - */ readonly getExperimentalFlags: () => Record | Promise>; - /** - * Resolves the engine's current feature list at request time. Backed by - * `IFeatureManager.units()` in production, so runtime retraction or a failed - * assembly is reflected in the very next response. - */ readonly getFeatures: () => MetaFeature[] | Promise; } diff --git a/packages/agent-gateway/src/routes/plugins.ts b/packages/agent-gateway/src/routes/plugins.ts index c3dab5caf..353ed622c 100644 --- a/packages/agent-gateway/src/routes/plugins.ts +++ b/packages/agent-gateway/src/routes/plugins.ts @@ -88,14 +88,7 @@ async function getSourceCheckoutLocation(): Promise; class PageTokenMismatchError extends Error {} -/** - * Map the core activity facts onto the v2 status enum. A pending interaction - * outranks an active turn (the turn is parked waiting on it). `failed` is - * observable live, and for cold sessions from the persisted outcome - * (completed/cancelled stay `idle`, matching the live fold). - */ export function mapActivityStatus( facts: SessionFacts, persistedLastTurnReason?: 'completed' | 'cancelled' | 'failed', diff --git a/packages/agent-gateway/src/search/contract.ts b/packages/agent-gateway/src/search/contract.ts index 9a27c8d7e..816f689df 100644 --- a/packages/agent-gateway/src/search/contract.ts +++ b/packages/agent-gateway/src/search/contract.ts @@ -1,30 +1,16 @@ export interface GlobalSearchQuery { - /** Keyword(s), required. */ readonly query: string; - /** - * 'terms' (default) — the word-level full-text index; 'literal' — exact - * substring match over the n-gram index (case-insensitive, NFKC-folded; - * needs at least 2 normalized characters). `op`/`sort` only apply to - * 'terms'; literal hits carry score 0 and sort by time desc. - */ readonly mode?: 'terms' | 'literal'; - /** Term combination, default AND. */ readonly op?: 'AND' | 'OR'; - /** Omit to search across every session. */ readonly container?: { readonly sessionId?: string; readonly agentId?: string; }; - /** Restrict to one document role. */ readonly role?: 'user' | 'assistant' | 'title'; - /** Epoch ms, inclusive bounds. */ readonly startTime?: number; readonly endTime?: number; - /** Default 'score' (relevance). */ readonly sort?: 'score' | 'time_desc' | 'time_asc'; - /** Default 20, max 50. */ readonly pageSize?: number; - /** Opaque cursor from the previous page; omit for the first page. */ readonly pageToken?: string; } @@ -34,11 +20,6 @@ export type GlobalSearchErrorReason = | 'readonly_index' | 'index_unavailable'; -/** - * Service-level error with a machine-readable reason. Lives in the contract - * (not the service module) so the search-index core — which also runs inside - * the search worker thread — can raise it without importing the service. - */ export class GlobalSearchError extends Error { constructor( readonly reason: GlobalSearchErrorReason, @@ -53,99 +34,33 @@ export interface GlobalSearchHit { readonly sessionId: string; readonly workspaceId: string; readonly sessionTitle: string; - /** 'main' or a subagent id. */ readonly agentId: string; readonly role: 'user' | 'assistant' | 'title'; - /** ~80-char window around the first hit term, generated server-side. */ readonly snippet: string; - /** Epoch ms of the wire record (session `updatedAt` for title docs). */ readonly time: number; - /** - * 0-based turn ordinal in the transcript view (same numbering as the - * `before_turn` pagination cursor of `GET /sessions/{id}/transcript` — the - * turn lives at `t`). The numbering is monotonic over the wire - * journal: compaction / clear do not renumber. Absent for title hits and - * for docs indexed before turn tracking; docs whose turns were later cut by - * an undo keep their pre-undo ordinal (no longer jumpable). - */ readonly turn?: number; - /** - * Transcript step id (`t.`, e.g. `t3.2`) of the step that - * produced this assistant text — the same id space as the transcript model - * (`packages/transcript` `model/ids.ts`), so a client can jump straight to - * the step. The ordinal is the engine's live step numbering (the wire - * record's `step` field); vacuous steps own no document, so ordinals may - * have gaps. Present only for assistant-role hits indexed after step - * tracking existed; docs whose turns were later cut by an undo keep their - * pre-undo id (no longer jumpable — same deviation as `turn`). - */ readonly stepId?: string; readonly score: number; } export interface GlobalSearchIndexState { - /** - * building — the first full sync has not finished yet, or the index base - * is (re)building after a no-generation fallback recovery — results may - * be incomplete; ready — a full sync completed in this process; - * readonly — another process holds the index write lock, this process only - * reads (catching up from the WAL in the background). - */ readonly state: 'building' | 'ready' | 'readonly'; - /** Progress counters behind `state`. */ readonly indexedSessions: number; readonly totalSessions: number; readonly documents: number; - /** - * True when the served page comes from a generation the service already - * knows to be behind: a newer index version was detected on disk - * (read-only refresh pending) or a background sync is in flight/queued. - * The results are still valid — just potentially not the freshest. - */ readonly stale?: boolean; - /** - * Set when the last background refresh/sync/reindex FAILED and the page is - * served from the previous (stale) generation: the error message, for - * observability. Absent when the last refresh succeeded. - */ readonly degraded?: string; } -/** - * Which backend served the page: - * - 'index' — the minidb full-text index (the default; always used when the - * container session is not live in this process); - * - 'live' — an in-memory scan of the live session's `TranscriptStore` - * (container-scoped queries on a session resumed in this process). - * Scores are only comparable within one source. - */ export type GlobalSearchSource = 'live' | 'index'; -/** - * Why a page may miss real hits (the query was bounded, never silently - * truncated): - * - 'candidate_cap' — the candidate set exceeded the confirmation cap, so - * confirmation stopped at the cap; - * - 'postings_budget' — the postings-visit budget stopped the index-side - * candidate scan early (hot term/n-gram), so candidates are a subset; - * - 'deadline' — the query's work budget (wall-clock deadline or processed - * text volume) ran out during matching/confirmation. - * A page token from a changed index generation is NOT reported here — it - * fails the request with `invalid_page_token` (see the file header). - */ export type GlobalSearchIncomplete = 'candidate_cap' | 'postings_budget' | 'deadline'; export interface GlobalSearchPage { readonly items: GlobalSearchHit[]; readonly hasMore: boolean; - /** Present iff `hasMore`. */ readonly pageToken?: string; readonly incomplete?: GlobalSearchIncomplete; readonly indexState: GlobalSearchIndexState; - /** - * The route that produced this page. The page token's fingerprint covers - * it: a route flip mid-pagination (e.g. the session closed) invalidates - * the token and the client must restart the search. - */ readonly source: GlobalSearchSource; } diff --git a/packages/agent-gateway/src/search/docs.ts b/packages/agent-gateway/src/search/docs.ts index b55d31ed2..5899ac42e 100644 --- a/packages/agent-gateway/src/search/docs.ts +++ b/packages/agent-gateway/src/search/docs.ts @@ -9,16 +9,7 @@ export interface MessageDoc { readonly role: 'user' | 'assistant'; readonly text: string; readonly time: number; - /** - * 0-based turn ordinal in the transcript view (groupTurns numbering). Absent - * for docs indexed before turn tracking existed. - */ readonly turn?: number; - /** - * Transcript step id (`t.`, engine live numbering from the wire - * record's `step` field) of the step that produced this assistant text. - * Absent for user docs and docs indexed before step tracking existed. - */ readonly stepId?: string; } @@ -27,7 +18,6 @@ export interface TitleDoc { readonly sessionId: string; readonly workspaceId: string; readonly sessionTitle: string; - /** Titles belong to the session, not an agent — always ''. */ readonly agentId: ''; readonly role: 'title'; readonly text: string; @@ -40,56 +30,27 @@ export interface TurnOpener { } export interface TurnCounterState { - /** Ordinal the next opened turn will get (0-based). */ readonly next: number; - /** Whether a turn is currently open (groupTurns' `ensureTurn` gate). */ readonly hasTurn: boolean; - /** Turn openers, in order — the replay stack for `context.undo`. */ readonly openers: readonly TurnOpener[]; } export interface StepTrackerState { - /** Current turn's step uuid → ordinal (the wire `step` field, else the fallback counter). */ readonly byUuid: Record; - /** `step.begin` count within the current turn — the fallback ordinal source. */ readonly begins: number; } export interface FileMetaDoc { readonly kind: 'fileMeta'; - /** Owning session, used to drop metas when a session disappears. */ readonly sessionId: string; - /** Doc-key coordinates of this file's documents (see `docKeyPrefix`). */ readonly agentId: string; readonly source: 'root' | 'agents'; - /** Absolute wire path (debugging aid; the key is its session + hash). */ readonly path: string; - /** Byte offset up to which the wire file has been indexed. */ readonly offset: number; readonly size: number; - /** - * File mtime/inode at the last sync pass. A changed inode (atomic - * replacement) or a bumped mtime at an unchanged size (in-place rewrite) - * forces a rescan even when `size === offset`. Absent in metas written - * before change tracking — such metas are simply refreshed with the - * current stat on the next pass, without a rescan. - */ readonly mtimeMs?: number; readonly ino?: number; - /** - * Turn counter state at `offset` — persisted with the watermark so an - * incremental pass resumes counting instead of restarting at turn 0. - * Absent in metas written before turn tracking; treated as the initial - * state, which makes a legacy meta resume mid-file with a zeroed counter — - * an accepted one-time drift, self-healing on the next shrink/rescan. - */ readonly turnState?: TurnCounterState; - /** - * Step tracker state at `offset` — persisted with the watermark for the - * same resume reason as `turnState`. Absent in metas written before step - * tracking: such a file is RESCANNED from scratch (docs dropped, offset - * reset) so stepIds are all-or-nothing per file instead of drifting. - */ readonly stepState?: StepTrackerState; } diff --git a/packages/agent-gateway/src/search/indexCore.ts b/packages/agent-gateway/src/search/indexCore.ts index 72450636d..253c852e8 100644 --- a/packages/agent-gateway/src/search/indexCore.ts +++ b/packages/agent-gateway/src/search/indexCore.ts @@ -127,64 +127,33 @@ function advanceStepTracker(state: StepTrackerState, effect: StepEffect): StepTr return { byUuid: { ...state.byUuid, [effect.uuid]: ordinal }, begins }; } -/** Minimal logger surface the core needs (the worker forwards these over RPC). */ export interface SearchCoreLog { info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; } export interface SearchCoreOptions { - /** Absolute path of the search-index database directory. */ readonly indexDir: string; readonly log: SearchCoreLog; - /** - * Unique-per-host-boot salt mixed into the token-pinning generation: a - * worker/process restart resets the local generation counter, and the - * salt makes tokens issued before the restart fail validation instead of - * colliding with the fresh counter (see `tokenGeneration`). - */ readonly bootSalt: string; - /** - * Fired synchronously when an open acquires the write lock — BEFORE the - * heavy recovery work runs. The worker entry forwards it to the host so a - * mid-open crash leaves a reapable lock. - */ readonly onLockToken?: (token: string) => void; } -/** - * One session to index, with its persistence directory PRE-RESOLVED by the - * caller (the main process owns `sessionDirOf`/`workspacePersistenceScope`; - * the worker closure deliberately does not import agent-core-v2). - */ export interface SyncSessionInput { readonly id: string; readonly workspaceId: string; readonly title?: string; readonly updatedAt: number; - /** Absolute session directory (the parent of wire.jsonl / agents/). */ readonly dir: string; } -/** The core's view of the served index, embedded in every search response. */ export interface CoreIndexView { readonly state: 'building' | 'ready' | 'readonly'; readonly indexedSessions: number; readonly documents: number; readonly readOnly: boolean; - /** - * Read-only-branch staleness: the on-disk fingerprint changed (a refresh - * is pending) or a refresh is in flight. Writer-side staleness is the - * caller's coordinator state and is OR'ed in by the service. - */ readonly freshnessStale: boolean; - /** Last background refresh failure, when serving a stale view. */ readonly degraded?: string; - /** - * Token of the db.lock line this core published (writer only) — the main - * process uses it to reap the lock file after a worker crash without ever - * deleting another owner's lock. - */ readonly lockToken?: string; } @@ -194,7 +163,6 @@ export type CoreSearchResult = readonly rows: MatchedRow[]; readonly hasMore: boolean; readonly incomplete?: GlobalSearchIncomplete; - /** Token-pinning generation (`bootSalt:counter`) — see tokenGeneration. */ readonly generation: string; readonly index: CoreIndexView; } @@ -202,34 +170,20 @@ export type CoreSearchResult = export interface CoreSearchParams { readonly q: NormalizedQuery; - /** Raw opaque token from the request; decoded (and generation-checked) here. */ readonly pageToken?: string; readonly budgets: SearchBudgets; } export interface CoreSyncOutcome { - /** True when the pass did not run (no db yet, or a read-only instance). */ readonly noop: boolean; readonly sessions: number; readonly documents: number; readonly lockToken?: string; - /** Post-pass lifecycle snapshot (stage 5) — keeps the host's cached - * aggregate state exact on the sync-first path, where no search/status - * response ever carries it. */ readonly lifecycle: CoreLifecycleReport; } type CoreSyncPassOutcome = Omit; -/** - * The aggregate lifecycle of the served index (stage 5): the diagnostic state - * machine behind the service's status surface — - * `stopped → opening → ready → building/degraded → closing`. Distinct from - * the per-page `CoreIndexView.state` (which answers "can this page serve hits - * now"): the lifecycle also covers the no-db phases and carries the failure - * detail, so logs/diagnostics can tell building, stale-serving, degraded, - * corrupt-rebuild and worker-unavailable apart. - */ export type CoreLifecycleState = | 'stopped' | 'opening' @@ -251,40 +205,19 @@ export interface CoreStatus { readonly readOnly: boolean; readonly lockToken?: string; readonly degraded?: string; - /** Post-open/post-refresh lifecycle snapshot (stage 5). */ readonly lifecycle: CoreLifecycleReport; } export class SearchIndexCore { - /** WAL watermark (bytes applied) for read-only catch-up. */ private walOffset = 0; private fingerprint = ''; private disposed = false; - /** - * Close-gate + drain for lifecycle-managed background ops (sync passes and - * read-only refreshes): close() closes the gate (new ops skip) and drains - * the in-flight ones BEFORE closing the db, so no background task ever - * touches a closed handle. - */ private readonly ops = new OpTracker(); - /** - * Identity of the published index base: bumped on every open/reopen - * (initial open, read-only swap, reindex) and on a sync pass that REPLACED - * already-indexed documents (shrink rescan, title overwrite). Page tokens - * pin it; additive/deletion-only passes deliberately keep it stable so - * keyset pagination over a live index is not constantly restarted (see - * contract.ts for the weak-consistency semantics). - */ private generation = 0; - /** Set by a sync pass when it replaced indexed documents → generation bump. */ private syncReplaced = false; - /** Last background refresh failure — surfaced as degraded. */ private lastRefreshError: { at: number; message: string } | null = null; - /** Last open failure — a search with no published generation fails fast. */ private openError: string | null = null; - /** One-time per-process migration flag for pre-v2 file-meta keys. */ private fileMetaMigrated = false; - /** Token of the published db.lock line (writer only) — see CoreIndexView. */ private lockToken: string | undefined; db: MiniDb | null = null; @@ -294,7 +227,6 @@ export class SearchIndexCore { constructor(private readonly options: SearchCoreOptions) {} - /** The published db.lock token (writer only) — see CoreIndexView.lockToken. */ get lockTokenView(): string | undefined { return this.lockToken; } @@ -330,26 +262,10 @@ export class SearchIndexCore { await this.publishDb(db, null); } - /** - * The generation page tokens pin: `:`. The local - * counter alone restarts from 0 when the worker (or, pre-workerization, - * the process) respawns — a token issued before the restart (say g=1) - * would otherwise validate against the fresh host's g=1 and silently - * paginate a different base. The boot salt makes every pre-restart token - * fail with `invalid_page_token` so the client restarts the search. - */ private tokenGeneration(): string { return `${this.options.bootSalt}:${this.generation}`; } - /** - * Swap a freshly opened db in as the new published generation: writer-side - * text-index definitions and the (handle-independent) fingerprint are - * computed BEFORE the swap, so a failure closes `next` and leaves `prev` - * (or the no-db state) untouched; the swap itself is one synchronous - * segment with no failure point between publishing `next` and closing - * `prev`. - */ private async publishDb(next: MiniDb, prev: MiniDb | null): Promise { let fingerprint: string; try { @@ -387,12 +303,6 @@ export class SearchIndexCore { }); } - /** - * The db.lock token this core published, so the main process can reap the - * lock after a worker crash. Read from the lock file (the LockFile - * instance's token is instance-private by design); anything unreadable or - * foreign-owned yields undefined and the reaper stays conservative. - */ private async readLockToken(): Promise { try { const raw = await readFile(join(this.indexDir, 'db.lock'), 'utf8'); @@ -404,18 +314,6 @@ export class SearchIndexCore { } } - /** - * Open the index db, rebuilding from scratch on unrecoverable corruption - * (the index is derived data — never repaired, only rebuilt). - * - * Rebuild is WRITER-ONLY: a process that fails to grab the write lock must - * never delete the directory out from under the live indexer. Lock state is - * not observable once `open` throws, so corruption is disambiguated with a - * probe open WITHOUT `onLockFail`: it throws `LockError` before recovery - * when another process holds the lock, and re-throws the corruption - * (releasing the lock) when the lock is free — in which case this process - * is the would-be writer and may rebuild. - */ private async openSearchDb(): Promise> { const opts = { dir: this.indexDir, @@ -451,24 +349,10 @@ export class SearchIndexCore { } } - /** - * Synchronously mark the core closed: in-flight sync/refresh passes skip - * their remaining work at the next `disposed` check, and no new background - * work starts. The async close() then drains and releases the handle. The - * split exists so a host's synchronous dispose() can gate mid-pass writes - * BEFORE awaiting anything (the pre-worker service did this with its - * synchronous `disposed = true`). - */ beginClose(): void { this.disposed = true; } - /** - * Close the core: close the op gate (new syncs / refreshes skip at - * enter()), wait for every in-flight op and the in-flight open to settle, - * then release and close the handle — no background task can touch a - * closed db. - */ async close(): Promise { this.disposed = true; await this.ops.close(); @@ -478,11 +362,6 @@ export class SearchIndexCore { if (db) await db.close().catch(() => {}); } - /** - * Run one lifecycle-managed background op under the close drain gate: - * skipped once close has started, and close waits for every op that - * already entered before it closes the db. - */ private async tracked(op: () => Promise): Promise { if (!this.ops.enter()) return; try { @@ -505,14 +384,6 @@ export class SearchIndexCore { return parts.join('|'); } - /** - * Bring a read-only instance up to date with the indexer's committed - * writes. Unchanged fingerprint → zero IO; WAL pure-append → incremental - * `catchUpFromWal`; anything else → open the replacement db and swap (which - * may also promote this process to indexer when the old writer's lock is - * gone). Single-flight; a failure is recorded in `lastRefreshError` and - * the stale generation keeps serving (surfaced as `indexState.degraded`). - */ refresh(): Promise { this.refreshPromise ??= this.tracked(() => this.doRefreshReadonly()) .then( @@ -614,12 +485,6 @@ export class SearchIndexCore { return { noop: false, sessions: indexed, documents: stats.documents }; } - /** - * One-time per-process migration of pre-v2 hash-only file-meta keys to the - * session-scoped format (`fileMetaKey`). A single full prefix scan of the - * meta namespace; per-session work afterwards only scans that session's - * keys. Idempotent — a crash mid-migration just rescans on the next pass. - */ private async migrateFileMetaKeys(db: MiniDb): Promise { if (this.fileMetaMigrated) return; const ops: BatchInputOp[] = []; @@ -826,11 +691,6 @@ export class SearchIndexCore { await db.batch(ops); } - /** - * Turn/step counting and doc extraction for one complete wire line. - * Returns the counter states advanced by the line (they are immutable and - * replaced per line, so the caller threads them through the chunk loop). - */ private collectWireLine( ops: BatchInputOp[], summary: SyncSessionInput, @@ -879,13 +739,6 @@ export class SearchIndexCore { return { turnState, stepState }; } - /** - * Serve one page from the currently published generation. Never waits for - * an open, sync, reopen or reindex: with no published base it answers with - * `building` semantics (or fails fast when the last open failed), and the - * page-token generation check runs against the base pinned at request - * time. - */ async search(params: CoreSearchParams): Promise { const { q, budgets } = params; const db = this.db; @@ -1023,11 +876,6 @@ export class SearchIndexCore { }; } - /** - * Full rebuild: close the handle, wipe the directory and reopen. The - * caller (the service) blocks new sync passes beforehand and runs the - * authoritative sync afterwards. - */ async reindex(): Promise { await this.ensureOpen(); if (this.db?.readOnly === true) { @@ -1049,15 +897,6 @@ export class SearchIndexCore { await this.ensureOpen(); } - /** - * Synchronous lifecycle snapshot (stage 5) — never kicks an open, never - * awaits: the diagnostic view of `stopped → opening → ready → - * building/degraded → closing`. 'degraded' means NO base is serving (the - * last open failed); a published base that keeps serving after a failed - * background refresh stays 'ready' — the failure rides the `degraded` - * message field of the page/status surfaces, same as the per-page - * `indexState` discipline. - */ lifecycleState(): CoreLifecycleReport { if (this.disposed) return { state: this.db === null ? 'stopped' : 'closing' }; const db = this.db; @@ -1090,7 +929,6 @@ export class SearchIndexCore { }; } - /** The view served while no queryable base is available. */ private buildingView(db?: MiniDb): CoreIndexView { const handle = db ?? this.db; const stats = handle?.get(STATS_KEY); diff --git a/packages/agent-gateway/src/search/match.ts b/packages/agent-gateway/src/search/match.ts index 5232ee1ac..2a8b35078 100644 --- a/packages/agent-gateway/src/search/match.ts +++ b/packages/agent-gateway/src/search/match.ts @@ -12,21 +12,7 @@ import type { MessageDoc, SearchDoc, TitleDoc } from './docs.ts'; export interface NormalizedQuery { readonly query: string; readonly mode: 'terms' | 'literal'; - /** - * Literal mode only: `normalizeLiteral(query)`, computed once and reused by - * candidate confirmation and the snippet anchor. The n-gram index's query - * tokenizer applies the same normalization to the query terms, so index and - * comparison agree by construction. - */ readonly literalQuery?: string; - /** - * Terms mode only: the query's deduplicated terms under minidb's default - * `tokenize` (the same tokenizer the 'body' text index applies to both - * sides). Computed once here so the live route's in-memory AND match agrees - * with the index route by construction. Empty when the query tokenizes to - * nothing (e.g. punctuation only) — both routes then match zero docs, - * mirroring `TextIndex.search`. - */ readonly termsQuery?: readonly string[]; readonly op: 'AND' | 'OR'; readonly container?: { readonly sessionId?: string; readonly agentId?: string }; @@ -37,7 +23,6 @@ export interface NormalizedQuery { readonly pageSize: number; } -/** Per-query work budget knobs (service fields, forwarded to the core). */ export interface SearchBudgets { readonly literalCandidateCap: number; readonly maxTextHits: number; @@ -46,13 +31,6 @@ export interface SearchBudgets { readonly queryTextBudgetChars: number; } -/** - * Sort boundary of the last returned hit — the keyset cursor: - * - literal mode / `time_desc` / `time_asc`: `[time, key]`; - * - `score` (terms mode): `[score, time, key]`. - * The key is the doc's stable identity: the minidb key on the index route, a - * synthetic per-frame key on the live route. - */ export type SortBoundary = readonly (number | string)[]; export type DecodedPage = @@ -60,26 +38,19 @@ export type DecodedPage = | { readonly kind: 'keyset'; readonly boundary: SortBoundary } | { readonly kind: 'legacy'; readonly skip: number }; -/** Boundary tuple width for the query's effective sort order. */ export function boundaryWidth(q: NormalizedQuery): 2 | 3 { return q.mode !== 'literal' && q.sort === 'score' ? 3 : 2; } -/** One matched document with its stable key and match context. */ export interface MatchedRow { readonly key: string; readonly value: MessageDoc | TitleDoc; readonly score: number; - /** Literal mode: offset of the confirmed match, reused as snippet anchor. */ readonly anchor?: number; } -/** Per-query work budget for the match/confirm phase (both routes). */ export interface MatchBudget { - /** Date.now() timestamp after which matching stops with 'deadline'. */ readonly deadlineAt: number; - /** Remaining document text (UTF-16 code units) literal confirmation may - * process before stopping with 'deadline'. */ textCharsLeft: number; } @@ -87,13 +58,6 @@ function cmpKey(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } -/** - * The query's total order (negative = `a` ranks before `b`): - * - literal mode (sort is a terms-mode concept) and `time_desc`: - * (time desc, key asc); - * - `time_asc`: (time asc, key asc); - * - `score`: (score desc, time desc, key asc). - */ export function compareRows(q: NormalizedQuery, a: MatchedRow, b: MatchedRow): number { if (q.mode !== 'literal' && q.sort === 'score') { return b.score - a.score || b.value.time - a.value.time || cmpKey(a.key, b.key); @@ -104,7 +68,6 @@ export function compareRows(q: NormalizedQuery, a: MatchedRow, b: MatchedRow): n return b.value.time - a.value.time || cmpKey(a.key, b.key); } -/** The boundary tuple of a row — the keyset cursor payload. */ export function boundaryOf(q: NormalizedQuery, row: MatchedRow): SortBoundary { return boundaryWidth(q) === 3 ? [row.score, row.value.time, row.key] : [row.value.time, row.key]; } @@ -124,12 +87,6 @@ function rowAfterBoundary(q: NormalizedQuery, row: MatchedRow, boundary: SortBou return cmp > 0; } -/** - * Bounded collector for the K best rows in the query's sort order — same - * worst-at-root heap shape as minidb's TopK: O(log K) per row and K rows in - * memory instead of an O(E log E) sort over every eligible row. Deep pages - * stay proportional to pageSize. - */ export class RowTopK { private readonly a: MatchedRow[] = []; @@ -170,7 +127,6 @@ export class RowTopK { } } - /** The kept rows in final rank order. */ sorted(): MatchedRow[] { return this.a.sort((x, y) => compareRows(this.q, x, y)); } @@ -178,16 +134,6 @@ export class RowTopK { const DEADLINE_CHECK_STRIDE = 64; -/** - * Container/role/time filtering, keyset-boundary filtering and literal - * confirmation — one implementation shared by the index route (confirming - * n-gram candidates) and the live route (scanning every in-memory - * document). The query work budgets apply at this match stage: the - * wall-clock deadline is re-checked every DEADLINE_CHECK_STRIDE candidates - * and literal confirmation additionally charges each processed document's - * text against `budget.textCharsLeft`. A budget stop is reported as - * `incomplete: 'deadline'`, never a silent truncation. - */ export function matchDocs( q: NormalizedQuery, docs: Iterable<{ key: string; value: SearchDoc | undefined; score: number }>, @@ -223,12 +169,6 @@ export function matchDocs( return { rows }; } -/** - * Sort, paginate and project the matched docs into a page (both routes). - * Keyset pages collect the best `pageSize + 1` rows past the boundary in a - * bounded heap; legacy v1 offset tokens get one last offset slice and are - * answered with a v2 keyset token. - */ export function paginateRows( q: NormalizedQuery, page: DecodedPage, @@ -251,14 +191,6 @@ export function paginateRows( return { pageRows, hasMore }; } -/** - * The page token encodes a fingerprint of the query conditions — changing - * conditions mid-pagination invalidates the token (same rule as Lark's - * search API). The serving route (`source`) is part of the fingerprint: a - * route flip mid-pagination (e.g. the container session closed and the live - * route fell away) invalidates the token too, so the client restarts the - * search instead of silently switching result sets. - */ export function tokenFingerprint(q: NormalizedQuery, source: GlobalSearchSource): string { const basis = JSON.stringify([ q.query, diff --git a/packages/agent-gateway/src/search/searchService.ts b/packages/agent-gateway/src/search/searchService.ts index 81a341334..25043c40f 100644 --- a/packages/agent-gateway/src/search/searchService.ts +++ b/packages/agent-gateway/src/search/searchService.ts @@ -77,12 +77,6 @@ async function pathExists(path: string): Promise { } } -/** - * `search_worker` — run the global search index in a dedicated worker - * thread (default ON). Disable via `PYTHINKER_CODE_EXPERIMENTAL_SEARCH_WORKER=false` - * or the `[experimental]` config section to fall back to the in-process - * (inline) host. Read once at service construction. - */ export const SEARCH_WORKER_FLAG_ID = 'search_worker'; registerFlagDefinition({ @@ -106,55 +100,24 @@ export async function drainGlobalSearchDisposals(): Promise { export interface IGlobalSearchService { readonly _serviceBrand: undefined; search(query: GlobalSearchQuery): Promise; - /** Full rebuild: wipe the index and rescan every wire file. */ reindex(): Promise<{ sessions: number; documents: number }>; - /** - * Diagnostic status (the `/api/v1/debug` surface reflects it). Never - * throws: a backend that cannot answer (failed open, worker down) reports - * a degraded lifecycle instead of rejecting. `lifecycle` is the aggregate - * state machine (stage 5): stopped → opening → ready → building/degraded → - * closing. NOTE the historical contract: the call may kick/await the - * backend's open and read-only refresh — use `lifecycleReport()` for a - * non-intrusive local read. - */ status(): Promise<{ sessions: number; documents: number; lastIndexedAt: number | null; - /** Identity of the published base; bumps invalidate v2 page tokens. */ generation: number; - /** Last background refresh/sync/reindex failure, if serving stale. */ degraded?: string; lifecycle: CoreLifecycleReport; }>; - /** - * Synchronous local lifecycle report (stage 5): never kicks an open, never - * spawns the worker, never awaits. Answers the transitional states - * (stopped/opening/degraded-backoff/closing) that status() would block on. - */ lifecycleReport(): CoreLifecycleReport; - /** - * Wire the live-transcript source for the in-memory search route. Called - * once from the composition root (start.ts) after `TranscriptService` is - * constructed; until then every search takes the index route. - */ setLiveTranscriptSource(source: LiveTranscriptSource): void; } export const IGlobalSearchService = createDecorator('globalSearch'); -/** - * Live-transcript access behind the in-memory (live) search route. - * Implemented by `TranscriptService` (`src/services/transcript/`); declared - * here with only the three methods the route needs, so the search module - * does not import the transcript service's dependency stack. - */ export interface LiveTranscriptSource { - /** Transcript store of a session live in this process; undefined when not in memory. */ forSessionLive(sessionId: string): TranscriptStore | undefined; - /** Resolves when the session's initial history backfill has landed. */ whenReady(sessionId: string): Promise; - /** Replay one agent's persisted history into the live store (idempotent per agent). */ ensureAgentHistory(sessionId: string, agentId: string): Promise; } @@ -191,20 +154,8 @@ function normalizeQuery(input: GlobalSearchQuery, maxQueryTerms: number): Normal }; } -/** The service's view of an execution host for the search-index core. */ export interface SearchBackend { - /** - * Synchronously stop accepting new background work (called from the - * service's synchronous dispose() before any awaiting): in-flight passes - * skip their remaining writes at the next gate check. - */ beginClose(): void; - /** - * Local, round-trip-free aggregate lifecycle (stage 5): the states a wedged - * or not-yet-started backend must be able to report without being asked - * (stopped/opening/degraded/closing). The full status() round trip refines - * the up states (ready/building) with exact stats. - */ lifecycleSnapshot(): CoreLifecycleReport; ensureOpen(): Promise; search(params: CoreSearchParams): Promise; @@ -215,7 +166,6 @@ export interface SearchBackend { dispose(): Promise; } -/** Rollback host: the search-index core running on the main thread. */ export class InlineSearchBackend implements SearchBackend { readonly core: SearchIndexCore; @@ -268,25 +218,18 @@ export class InlineSearchBackend implements SearchBackend { export class GlobalSearchService implements IGlobalSearchService { declare readonly _serviceBrand: undefined; - /** Minimum interval between search-triggered sync passes (test knob). */ syncDebounceMs = 2_000; - /** Literal-mode candidate cap (test knob, see LITERAL_CANDIDATE_CAP). */ literalCandidateCap = LITERAL_CANDIDATE_CAP; - /** Terms-mode candidate cap (test knob, see MAX_TEXT_HITS). */ maxTextHits = MAX_TEXT_HITS; - /** Postings-visit budget per query (test knob, see MAX_POSTINGS_VISITS). */ postingsVisitBudget = MAX_POSTINGS_VISITS; - /** Match/confirm wall-clock budget per query (test knob). */ queryDeadlineMs = QUERY_DEADLINE_MS; - /** Literal-confirmation text-volume budget per query (test knob). */ queryTextBudgetChars = QUERY_TEXT_BUDGET_CHARS; - /** Max distinct query terms in terms mode (test knob). */ maxQueryTerms = MAX_QUERY_TERMS; private readonly backend: SearchBackend; @@ -295,17 +238,11 @@ export class GlobalSearchService implements IGlobalSearchService { private lastSyncStartedAt = 0; private summaries = new Map(); private disposed = false; - /** Set while `reindex()` swaps the db — syncs started meanwhile are no-ops. */ private reindexing = false; - /** Live-transcript source for the in-memory route; null until start.ts wires it. */ private liveSource: LiveTranscriptSource | null = null; - /** One queued follow-up pass behind the in-flight one (backpressure). */ private syncQueued = false; - /** Trailing-pass timer behind the debounce window. */ private syncTimer: ReturnType | null = null; - /** Last background sync/reindex/worker failure — surfaced as degraded. */ private lastRefreshError: { at: number; message: string } | null = null; - /** Set when dispose()'s async drain finished — lifecycle 'stopped'. */ private drainSettled = false; constructor( @@ -397,7 +334,6 @@ export class GlobalSearchService implements IGlobalSearchService { ); } - /** Single-flight: concurrent callers share the in-flight sync. */ private ensureSyncStarted(): Promise { if (this.syncPromise === null) { const p = this.runSync().finally(() => { @@ -433,17 +369,6 @@ export class GlobalSearchService implements IGlobalSearchService { return out; } - /** - * Drive a read-only refresh of the backend (single-flight facade). - * Production note: request-path searches no longer call this — the core - * kicks read-only refreshes internally and reports freshness via - * `CoreIndexView.freshnessStale`. The facade remains as the deterministic - * refresh drive for tests (`refreshNow`) and its promise feeds the - * read-only-side `stale` bit while an explicit refresh is in flight. The - * core records refresh failures itself (surfaced as `degraded`); only - * worker-availability failures land in the rejection branch here — a - * failed refresh must never fail the search that kicked it. - */ private refreshReadonly(): Promise { if (this.refreshPromise === null) { this.refreshPromise = this.backend.refresh().then( @@ -462,13 +387,6 @@ export class GlobalSearchService implements IGlobalSearchService { return this.refreshPromise; } - /** - * Route: a container-scoped query on a session that is live in this process - * scans the in-memory transcript store instead of the index, in both terms - * and literal mode. Anything else takes the index route. The live route - * never falls back on error — the store being in hand means the session is - * alive, so a scan failure is a real error, not a degradation signal. - */ async search(input: GlobalSearchQuery): Promise { const q = normalizeQuery(input, this.maxQueryTerms); const sessionId = q.container?.sessionId; @@ -531,17 +449,6 @@ export class GlobalSearchService implements IGlobalSearchService { }; } - /** - * Flatten the live transcript store into the same document shape the index - * route searches (`MessageDoc` / `TitleDoc`), each with a stable synthetic - * key for keyset pagination: - * - one user doc per non-empty `turn.prompt` (turn ordinal + turn time); - * - one assistant doc per assistant-role text frame (turn ordinal + - * stepId); thinking / tool / notice frames are skipped; - * - one title doc from the session-index summary, same as the sync path. - * Text is trimmed and empty results skipped, mirroring the index side's - * `wireExtract` (which trims both user and assistant text). - */ private async collectLiveDocs( sessionId: string, store: TranscriptStore, @@ -725,12 +632,6 @@ export class GlobalSearchService implements IGlobalSearchService { }; } - /** - * Merge the backend's index view with the coordinator's writer-side state: - * a page is stale when the backend knows its view is behind (read-only - * freshness) OR a sync pass is in flight/queued/pending behind the - * debounce window. - */ private composeIndexState(view: CoreIndexView): GlobalSearchIndexState { const coordinatorStale = view.readOnly ? this.refreshPromise !== null @@ -747,14 +648,6 @@ export class GlobalSearchService implements IGlobalSearchService { }; } - /** - * The page served while the index base is unavailable: the first full sync - * has not finished yet (no db yet), a deferred open-time base build is - * still running / finally failed on the served handle, or the search - * worker is down. Same "never wait" rule as every other request path — - * the background coordinator/build catches up and a later search serves - * real hits. - */ private buildingPage(view: CoreIndexView | null): GlobalSearchPage { const indexed = view?.indexedSessions ?? 0; const readOnly = view?.readOnly === true; @@ -825,15 +718,6 @@ export class GlobalSearchService implements IGlobalSearchService { } } - /** - * Synchronous LOCAL lifecycle report (stage 5): never kicks an open, never - * spawns the worker, never awaits — the view that still answers DURING a - * minutes-long first open (or while the worker backs off), where status() - * would block. The up states (ready/building) come from the backend's - * cached last response and may lag one RPC; status() is the exact, - * round-trip variant. Reflected on the `/api/v1/debug` surface like every - * Service method. - */ lifecycleReport(): CoreLifecycleReport { if (this.disposed) return { state: this.drainSettled ? 'stopped' : 'closing' }; return this.backend.lifecycleSnapshot(); diff --git a/packages/agent-gateway/src/search/snippet.ts b/packages/agent-gateway/src/search/snippet.ts index cf8fcec0f..4e24957c2 100644 --- a/packages/agent-gateway/src/search/snippet.ts +++ b/packages/agent-gateway/src/search/snippet.ts @@ -2,7 +2,6 @@ function collapseWs(s: string): string { return s.replaceAll(/\s+/g, ' ').trim(); } -/** Query terms for locating: whitespace-split words plus the whole query. */ export function snippetTerms(query: string): string[] { const terms = query .split(/\s+/) @@ -13,14 +12,6 @@ export function snippetTerms(query: string): string[] { return terms; } -/** - * `anchor` — a caller-known hit location (`at` = offset of the match in - * `text`, `len` = match length in code units), e.g. the confirmation offset - * from literal search. When given, the term-guessing pass is skipped. The - * window math clamps out-of-range offsets, so an anchor taken from a - * normalized copy of the text (NFKC can shift offsets) degrades to a - * slightly shifted window, never an error. - */ export function makeSnippet( text: string, query: string, diff --git a/packages/agent-gateway/src/search/wireExtract.ts b/packages/agent-gateway/src/search/wireExtract.ts index 0c4e4b340..d21234cdc 100644 --- a/packages/agent-gateway/src/search/wireExtract.ts +++ b/packages/agent-gateway/src/search/wireExtract.ts @@ -3,51 +3,16 @@ import { matchSingleMediaPathTag } from '@pymodel/agent-core-v2/agent/media/medi export interface ExtractedWireMessage { readonly role: 'user' | 'assistant'; readonly text: string; - /** Epoch ms; undefined when the record carries no usable time. */ readonly time?: number; - /** - * Owning step of an assistant text (the `content.part` event's `stepUuid`); - * user messages carry no step. - */ readonly stepUuid?: string; } -/** - * How one wire record moves the 0-based turn counter (transcript groupTurns - * rules): - * - `open` — a user message that starts a new turn; `anchor` marks undo - * anchors (`isUndoAnchor`: no origin / kind 'user' / user-slash skill or - * plugin command), needed to replay `context.undo` on the counter; - * - `ensure` — assistant content; attaches to the current turn, opening a - * fallback turn when none exists yet (groupTurns' `ensureTurn`). Limited - * to the loop events whose folded assistant message SURVIVES settling - * (`content.part` with non-vacuous text, or `tool.call` — a tool.result - * folds to a tool message, and a vacuous step is dropped, so neither of - * those opens a turn); - * - `undo` — `context.undo`: drop the last `count` anchor-opened turns; - * - `none` — anything else. In particular `context.apply_compaction` and - * `context.clear` do NOT renumber: the transcript's cold replay keeps the - * full history (compaction appends a `compaction_summary` marker message, - * `clear` only raises a floor) and groupTurns numbers it continuously, - * matching the live TurnModel whose turn ids are monotonic. - */ export type TurnEffect = | { readonly kind: 'open'; readonly anchor: boolean } | { readonly kind: 'ensure' } | { readonly kind: 'undo'; readonly count: number } | { readonly kind: 'none' }; -/** - * How one wire record moves the per-turn step tracker: - * - `begin` — `step.begin`: map `uuid` to its step ordinal. `ordinal` is the - * wire record's own `step` field (the engine's live 1-based numbering, - * which the transcript's step ids `t.` use); absent on records - * too old to carry it — the tracker then falls back to counting begins - * within the turn (v1 loops had no loop-level retries, so counting equals - * the surviving-step numbering); - * - `none` — anything else. In particular `step.end` does NOT unmap: the - * mapping is reset at turn boundaries, not per step. - */ export type StepEffect = | { readonly kind: 'begin'; readonly uuid: string; readonly ordinal?: number } | { readonly kind: 'none' }; @@ -173,7 +138,6 @@ function turnEffectOfAppendMessage(message: unknown): TurnEffect { return { kind: 'open', anchor }; } -/** Full reading of one wire.jsonl line; unparseable lines analyze to zero. */ export function analyzeWireLine(line: string): WireLineAnalysis { const r = parseWireLine(line); if (r === undefined) return { messages: [], turn: NONE, step: STEP_NONE }; @@ -259,10 +223,6 @@ export function analyzeWireLine(line: string): WireLineAnalysis { return { messages: [], turn: NONE, step: STEP_NONE }; } -/** - * Extract indexable messages from one wire.jsonl line. Unparseable lines and - * record types outside the two indexed shapes yield an empty array. - */ export function extractFromWireLine(line: string): ExtractedWireMessage[] { return analyzeWireLine(line).messages; } diff --git a/packages/agent-gateway/src/search/worker/dev-hooks.mjs b/packages/agent-gateway/src/search/worker/dev-hooks.mjs index 6bc9a4a31..b752597a0 100644 --- a/packages/agent-gateway/src/search/worker/dev-hooks.mjs +++ b/packages/agent-gateway/src/search/worker/dev-hooks.mjs @@ -1,12 +1,3 @@ -// Resolve hook for the DEV search worker (`--experimental-transform-types`). -// -// The worker entry's import closure runs from TypeScript source in dev and -// tests. minidb's internals import sibling modules with `.js` specifiers -// while the files on disk are `.ts` (the repo builds with bundler module -// resolution), and Node's native type stripping does no specifier remapping -// — so retry a missing relative `.js` specifier as `.ts` here. Registered by -// register-dev-hooks.mjs via `--import`; never bundled (the bundled worker -// is plain JS and needs no hook). export async function resolve(specifier, context, nextResolve) { try { return await nextResolve(specifier, context); diff --git a/packages/agent-gateway/src/search/worker/host.ts b/packages/agent-gateway/src/search/worker/host.ts index edf5591e4..5d4e8a1dc 100644 --- a/packages/agent-gateway/src/search/worker/host.ts +++ b/packages/agent-gateway/src/search/worker/host.ts @@ -35,11 +35,6 @@ export type SearchWorkerErrorCode = | 'backoff' | 'disposed'; -/** - * Recognizable worker-availability failure. The service maps it to a - * building/degraded response (searches) or the degraded state (background - * ops) — it is never swallowed silently. - */ export class SearchWorkerError extends Error { constructor( readonly code: SearchWorkerErrorCode, @@ -51,20 +46,13 @@ export class SearchWorkerError extends Error { } export interface SearchWorkerHostOptions { - /** Absolute path of the search-index database directory. */ readonly dir: string; readonly log: SearchCoreLog; - /** Ready-handshake budget (ms). Default 15_000. */ readonly readyTimeoutMs?: number; - /** Grace period for the drain-on-close before terminate() (ms). Default 30_000. */ readonly closeTimeoutMs?: number; - /** Watchdog budget per query/lifecycle request (ms). Default 60_000. */ readonly requestTimeoutMs?: number; - /** Watchdog budget per sync/reindex request (ms). Default 30 min. */ readonly syncTimeoutMs?: number; - /** Worker heap cap, mirroring the text-build worker. Default 1024. */ readonly maxOldSpaceMb?: number; - /** Test hook: worker factory override. */ readonly workerFactory?: (entry: { url: URL; data: SearchWorkerData; execArgv: string[] }) => Worker; } @@ -92,12 +80,10 @@ const ORPHAN_LOCK_GRACE_MS = 250; const liveLockTokens = new Set(); -/** Register a held search-index lock token (inline backend's core). */ export function noteLiveLockToken(token: string): void { liveLockTokens.add(token); } -/** Drop a previously registered token (inline backend close/dispose). */ export function dropLiveLockToken(token: string | undefined): void { if (token !== undefined) liveLockTokens.delete(token); } @@ -108,7 +94,6 @@ export class SearchWorkerHost { private reapPromise: Promise | null = null; private readonly requests = new Map(); private nextId = 1; - /** Token of the db.lock line the current/last worker published. */ private lockToken: string | undefined; private failures = 0; private nextRetryAfter = 0; @@ -117,12 +102,6 @@ export class SearchWorkerHost { private exiting = false; private exitResolve: (() => void) | null = null; private orphanCheckScheduled = false; - /** - * The worker-side core's lifecycle as of the last RPC response that carried - * it (every response shape does: status/open/refresh/reindex directly, - * search via its index view). Feeds `lifecycleSnapshot` — the local, - * round-trip-free answer for the service's status surface. - */ private lastCoreLifecycle: CoreLifecycleReport | null = null; constructor(private readonly options: SearchWorkerHostOptions) {} @@ -135,18 +114,10 @@ export class SearchWorkerHost { return this.options.dir; } - /** Test/diagnostic: the lock token the current/last worker reported. */ get reportedLockToken(): string | undefined { return this.lockToken; } - /** - * The aggregate search lifecycle from the host's LOCAL knowledge (stage 5): - * never spawns the worker, never waits on an RPC — the states a wedged or - * not-yet-running worker cannot answer for itself are exactly the ones this - * must report (stopped/opening/degraded-backoff/closing). A live worker's - * state comes from the cached last response (`lastCoreLifecycle`). - */ lifecycleSnapshot(): CoreLifecycleReport { if (this.exiting) { return this.worker !== null || this.spawnPromise !== null @@ -194,7 +165,6 @@ export class SearchWorkerHost { return this.call('status'); } - /** Test hook: hard-kill the worker (crash/restart semantics). */ async killWorkerForTest(): Promise { const worker = this.worker; if (worker === null) return; @@ -246,11 +216,6 @@ export class SearchWorkerHost { }); } - /** - * Watchdog: a request outlived its budget — the worker is presumed wedged. - * Reject the requester and take the crash path (terminate → reject the - * remaining in-flight requests → reap → backed-off restart). - */ private onRequestTimeout(id: number): void { const pending = this.requests.get(id); if (pending === undefined) return; @@ -277,13 +242,6 @@ export class SearchWorkerHost { pending.resolve(result); } - /** - * Track the worker-published db.lock token and the read-only role from any - * response carrying them; a read-only answer triggers the orphan-lock - * detector (see recoverOrphanedLock). Also refreshes the cached core - * lifecycle (`lastCoreLifecycle`) — every response shape carries it: - * status/open/refresh/reindex directly, search via its index view. - */ private noteResult(result: unknown): void { if (result === null || typeof result !== 'object') return; const direct = (result as { lockToken?: unknown }).lockToken; @@ -491,7 +449,6 @@ export class SearchWorkerHost { }); } - /** Record a spawn/handshake failure and arm the restart backoff. */ private noteFailure(message: string): void { this.failures += 1; const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.failures - 1), BACKOFF_CAP_MS); @@ -499,12 +456,6 @@ export class SearchWorkerHost { this.lastFailure = message; } - /** - * Remove the search-index db.lock ONLY when it still carries the dead - * worker's own token. A lock line written by anyone else (a live worker of - * another service instance in this process, another process entirely) is - * never touched. - */ private async reapLockFile(token: string | undefined): Promise { if (token === undefined) return; const lockPath = join(this.dir, 'db.lock'); @@ -570,13 +521,6 @@ export class SearchWorkerHost { }); } - /** - * Synchronously stop accepting new work (the service's dispose() calls - * this before any awaiting): every later RPC fails fast with 'disposed', - * and the worker's core is told NOW (control message) so an in-flight - * sync abandons its remaining work at the next checkpoint instead of - * wedging the dispose behind a running pass. - */ beginClose(): void { this.exiting = true; try { @@ -585,11 +529,6 @@ export class SearchWorkerHost { } } - /** - * A read-only answer while the lock could be an orphaned same-pid line - * (left by a dead worker whose token event never landed) schedules a - * re-check after a grace window — see recoverOrphanedLock. - */ private scheduleOrphanCheck(): void { if (this.orphanCheckScheduled || this.exiting) return; this.orphanCheckScheduled = true; @@ -600,15 +539,6 @@ export class SearchWorkerHost { timer.unref?.(); } - /** - * Safety net behind the `lockToken` event: this instance is being served - * read-only while the lock line carries THIS process's pid. The line is - * legitimate only when its token belongs to a live worker of this process - * (`liveLockTokens`); anything else is an orphan left by a dead worker - * (its token event was lost with it) — reap it and restart the worker so - * the next request reopens as the writer instead of silently serving a - * frozen read-only view until process exit. - */ private async recoverOrphanedLock(): Promise { if (this.exiting || this.worker === null) return; const lockPath = join(this.dir, 'db.lock'); diff --git a/packages/agent-gateway/src/search/worker/protocol.ts b/packages/agent-gateway/src/search/worker/protocol.ts index c2f61036f..598969724 100644 --- a/packages/agent-gateway/src/search/worker/protocol.ts +++ b/packages/agent-gateway/src/search/worker/protocol.ts @@ -10,31 +10,12 @@ import type { export const SEARCH_WORKER_PROTOCOL_VERSION = 1; -/** `workerData` payload for the search worker. */ export interface SearchWorkerData { - /** Absolute path of the search-index database directory. */ readonly dir: string; - /** - * Unique-per-spawn boot identifier, mixed into the index generation the - * worker reports: page tokens issued before a worker restart NEVER - * validate against the respawned worker (its local generation counter - * restarts from 0), so mid-pagination clients get `invalid_page_token` - * and restart the search instead of drifting across two generations that - * happen to share a number. - */ readonly bootSalt: string; - /** - * Packaged (SEA-extracted) minidb text-build worker file. The main process - * already extracted it for its own minidb; the search worker configures - * its own copy of the runtime so full-text generation builds triggered by - * the search-index MiniDb keep running in a nested worker instead of - * inline in the search worker. Absent in dev (minidb resolves its own - * sibling .ts entry) and in plain bundles (inline-in-worker fallback). - */ readonly textBuildWorkerPath?: string; } -/** RPC calls: carry a request id and always produce a response. */ export type SearchWorkerCall = | { readonly id: number; readonly v: number; readonly type: 'open' } | { readonly id: number; readonly v: number; readonly type: 'search'; readonly params: CoreSearchParams } @@ -46,29 +27,16 @@ export type SearchWorkerCall = export type SearchWorkerCallType = SearchWorkerCall['type']; -/** - * Control message (no id, no response): flip the worker-side core into - * closing state NOW so an in-flight sync abandons its remaining work at the - * next checkpoint — the host's beginClose()/dispose() must never be wedged - * behind a running pass. - */ export interface SearchWorkerControlMessage { readonly v: number; readonly type: 'beginClose'; } -/** Everything the host may post to the worker. */ export type SearchWorkerRequest = SearchWorkerCall | SearchWorkerControlMessage; -/** Response payload of the `open` / `refresh` / `reindex` requests. */ export interface SearchWorkerOpenResult { readonly readOnly: boolean; readonly lockToken?: string; - /** - * The core's lifecycle right after the call (stage 5): lets the host keep - * its cached aggregate state exact across worker (re)opens — an open that - * published a still-building text base reports 'building', not 'ready'. - */ readonly lifecycle: CoreLifecycleReport; } @@ -82,7 +50,6 @@ export interface SearchWorkerResultMap { readonly close: null; } -/** Serializable failure: typed search errors keep their reason. */ export interface SearchWorkerErrorPayload { readonly message: string; readonly reason?: GlobalSearchErrorReason; @@ -97,13 +64,6 @@ export type SearchWorkerEvent = readonly meta?: Record; } | { - /** - * Fired the moment the worker's MiniDb acquires the write lock — - * BEFORE the heavy open work (WAL replay / generation load) runs. The - * host needs the token up front: a worker that dies mid-open - * otherwise leaves a lock line carrying this process's (still alive) - * pid, which minidb's pid-liveness rule can never reclaim. - */ readonly type: 'lockToken'; readonly token: string; } diff --git a/packages/agent-gateway/src/search/worker/register-dev-hooks.mjs b/packages/agent-gateway/src/search/worker/register-dev-hooks.mjs index b426debeb..4ed69ed7b 100644 --- a/packages/agent-gateway/src/search/worker/register-dev-hooks.mjs +++ b/packages/agent-gateway/src/search/worker/register-dev-hooks.mjs @@ -1,7 +1,3 @@ -// Registers the dev search worker's module hooks (see dev-hooks.mjs). -// Passed to the worker via execArgv `--import `; the specifier is -// resolved relative to this file, so the hook module rides along regardless -// of the process cwd. Never bundled. import { register } from 'node:module'; register('./dev-hooks.mjs', import.meta.url); diff --git a/packages/agent-gateway/src/search/worker/runtime.ts b/packages/agent-gateway/src/search/worker/runtime.ts index 0ab76819d..9c84fcdf2 100644 --- a/packages/agent-gateway/src/search/worker/runtime.ts +++ b/packages/agent-gateway/src/search/worker/runtime.ts @@ -7,7 +7,6 @@ export type SearchWorkerRuntimeState = let configuredPath: string | null = null; -/** Configure the packaged search worker entry once during process startup. */ export function configureSearchWorkerRuntime(entry: string): SearchWorkerRuntimeState { if (!path.isAbsolute(entry)) { throw new TypeError('search worker entry must be an absolute path'); @@ -34,7 +33,6 @@ export function configureSearchWorkerRuntime(entry: string): SearchWorkerRuntime return { configured: true, path: configuredPath }; } -/** Reset process-wide configuration. Intended for tests and controlled hosts. */ export function resetSearchWorkerRuntime(): void { configuredPath = null; } diff --git a/packages/agent-gateway/src/security/bindClassify.ts b/packages/agent-gateway/src/security/bindClassify.ts index 249cda6cb..f2c1e37aa 100644 --- a/packages/agent-gateway/src/security/bindClassify.ts +++ b/packages/agent-gateway/src/security/bindClassify.ts @@ -3,7 +3,6 @@ import net from 'node:net'; export type BindClass = 'loopback' | 'lan' | 'public'; export interface ClassifyOptions { - /** Override classification of wildcard binds (`0.0.0.0` / `::` / empty). */ readonly bindClass?: 'lan' | 'public'; } @@ -46,13 +45,6 @@ function isLinkLocalV6(host: string): boolean { return first >= 0xfe80 && first <= 0xfebf; } -/** - * Classify a bind host by the network exposure it implies. - * - * See the module header for the tier definitions. A non-IP hostname that is - * not `localhost` is treated conservatively as `public` — a DNS name could - * resolve to a public address. - */ export function classify(host: string, opts?: ClassifyOptions): BindClass { if (host === '' || host === '0.0.0.0' || host === '::') { return opts?.bindClass ?? 'public'; diff --git a/packages/agent-gateway/src/services/auth/authTokenService.ts b/packages/agent-gateway/src/services/auth/authTokenService.ts index 84b88f373..0c0f9aa81 100644 --- a/packages/agent-gateway/src/services/auth/authTokenService.ts +++ b/packages/agent-gateway/src/services/auth/authTokenService.ts @@ -6,28 +6,14 @@ import type { TokenStore } from './tokenStore'; export interface IAuthTokenService { readonly _serviceBrand: undefined; - /** The persistent bearer token (re-read from disk when its mtime changes). */ getToken(): string; - /** - * True when `candidate` matches the persistent token OR verifies against the - * configured password hash. Constant-time on the token path; bcrypt on the - * password path. - */ isValid(candidate: string): Promise; } export const IAuthTokenService = createDecorator('authTokenService'); -/** - * Default `IAuthTokenService` over a `TokenStore` + optional password hash. - * - * Constructed in `start.ts` (M5.1) where the async `TokenStore` / - * `passwordHash` are available, then injected via `serviceOverrides`. NOT built - * inside `createServerServiceCollection`: that path is synchronous and cannot - * await the `TokenStore` file write or the bcrypt hash. - */ export function createAuthTokenService(deps: { readonly tokenStore: TokenStore; readonly passwordHash: string | undefined; diff --git a/packages/agent-gateway/src/services/auth/persistentToken.ts b/packages/agent-gateway/src/services/auth/persistentToken.ts index eac4129bb..ea0c7c869 100644 --- a/packages/agent-gateway/src/services/auth/persistentToken.ts +++ b/packages/agent-gateway/src/services/auth/persistentToken.ts @@ -3,28 +3,20 @@ import { join } from 'node:path'; import { readPrivateFile, writePrivateFile } from './privateFiles'; -/** On-disk filename for the persistent token, relative to PYTHINKER_CODE_HOME. */ export const SERVER_TOKEN_FILE = 'server.token'; -/** Absolute path of the persistent token file for a given home dir. */ export function serverTokenPath(homeDir: string): string { return join(homeDir, SERVER_TOKEN_FILE); } -/** Fresh 256-bit token, base64url-encoded (43 chars, URL-safe). */ export function generateServerToken(): string { return randomBytes(32).toString('base64url'); } -/** Atomically write `token` to `/server.token` (0600). */ export async function writeServerToken(homeDir: string, token: string): Promise { await writePrivateFile(serverTokenPath(homeDir), token); } -/** - * Read the persistent token, or `undefined` when no token file exists yet. - * Throws if the file exists but is too permissive (not 0600). - */ export async function readServerToken(homeDir: string): Promise { try { const buf = await readPrivateFile(serverTokenPath(homeDir)); @@ -37,10 +29,6 @@ export async function readServerToken(homeDir: string): Promise { const existing = await readServerToken(homeDir); if (existing !== undefined && existing.length > 0) { @@ -51,13 +39,6 @@ export async function loadOrCreateServerToken(homeDir: string): Promise return token; } -/** - * Generate and persist a brand-new token, invalidating the previous one. - * - * A running server picks the new token up on its next auth check (the token - * store re-reads the file when its mtime changes), so rotation takes effect - * immediately without a restart. - */ export async function rotateServerToken(homeDir: string): Promise { const token = generateServerToken(); await writeServerToken(homeDir, token); diff --git a/packages/agent-gateway/src/services/auth/tokenStore.ts b/packages/agent-gateway/src/services/auth/tokenStore.ts index bb24aec24..2967a0661 100644 --- a/packages/agent-gateway/src/services/auth/tokenStore.ts +++ b/packages/agent-gateway/src/services/auth/tokenStore.ts @@ -10,17 +10,6 @@ export interface TokenStore { dispose(): Promise; } -/** - * Persistent token store over `/server.token`. - * - * The token is loaded (or generated) once at boot and reused across restarts. - * `getToken()`/`isValid()` re-read the file whenever its mtime changes, so a - * `pythinker web rotate-token` (which rewrites the file) takes effect on a - * running server immediately — no restart, no extra API. The file is small - * (43 bytes) and the common path is a single `statSync` per check. - * - * `dispose()` is intentionally a no-op: the token must survive shutdown. - */ export async function createTokenStore(homeDir: string): Promise { const tokenPath = serverTokenPath(homeDir); const initial = await loadOrCreateServerToken(homeDir); diff --git a/packages/agent-gateway/src/services/guiStore/guiStore.ts b/packages/agent-gateway/src/services/guiStore/guiStore.ts index ac0a6f815..774a30713 100644 --- a/packages/agent-gateway/src/services/guiStore/guiStore.ts +++ b/packages/agent-gateway/src/services/guiStore/guiStore.ts @@ -1,11 +1,5 @@ import { createDecorator } from '@pymodel/agent-core-v2'; -/** - * `IGuiStoreService` — a server-backed key/value store mirroring the browser - * `localStorage` interface (`getItem` / `setItem` / `removeItem` / `clear` / - * `length`). Values are opaque strings; callers (the web UI) handle their own - * serialization. Persisted to `/gui.toml`. - */ export interface IGuiStoreService { readonly _serviceBrand: undefined; getItem(key: string): Promise; diff --git a/packages/agent-gateway/src/services/guiStore/guiStoreService.ts b/packages/agent-gateway/src/services/guiStore/guiStoreService.ts index 564b1d54a..d0964402c 100644 --- a/packages/agent-gateway/src/services/guiStore/guiStoreService.ts +++ b/packages/agent-gateway/src/services/guiStore/guiStoreService.ts @@ -6,7 +6,6 @@ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; import { IGuiStoreService } from './guiStore'; -/** Minimal logger surface — keeps the store decoupled from the server logger. */ export interface GuiStoreLogger { warn(obj: unknown, msg: string): void; } diff --git a/packages/agent-gateway/src/services/legacyStatus/legacyStatus.ts b/packages/agent-gateway/src/services/legacyStatus/legacyStatus.ts index f627db4a3..e3bcab841 100644 --- a/packages/agent-gateway/src/services/legacyStatus/legacyStatus.ts +++ b/packages/agent-gateway/src/services/legacyStatus/legacyStatus.ts @@ -11,11 +11,6 @@ import { import type { AgentActivityState } from '@pymodel/agent-core-v2'; import type { TurnEndReason } from '@pymodel/agent-core-v2/agent/loop/turnEvents'; -/** - * The v1 `phase` field of the combined `agent.status.updated` payload — a - * v1-only concept with no producer on the v2 side (v2's native status events - * never carry it), so it is defined here at the v1 edge that projects it. - */ export type AgentPhase = | { readonly kind: 'idle' } | { @@ -82,12 +77,10 @@ export type AgentPhase = export interface LegacyStatusSnapshot { readonly usage?: UsageStatus; readonly contextTokens: number; - /** Omitted when the context limit is unknown — 0 is never pushed (0 is the engine's "unknown" marker, not a real limit). */ readonly maxContextTokens?: number; readonly model: string; } -/** Read the current combined status when the handle exposes a complete agent. */ export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot | undefined { const profile = agent.accessor.get(IAgentProfileService) as | IAgentProfileService @@ -133,19 +126,6 @@ function defaultModelContextTokens(agent: IAgentScopeHandle): number | undefined } } -/** - * Map the native v2 `AgentActivityState` to the legacy v1 `AgentPhase` - * (`agent.status.updated` payload). Pure function — kept at the agent-gateway - * edge so the core engine stays free of v1 wire-compatibility concerns. - * - * Returns `undefined` for `disposing` / `disposed`, which have no v1 - * concept (emitting `idle` would mislead the UI). - * - * Three deliberate v1 divergences from the naive mapping (see status-refactor - * plan 04 §3): a parallel approval resolve keeps `awaiting_approval` while any - * approval is still pending (no premature `running`); `interrupted` carries the - * `endingReason`; `disposing`/`disposed` emit nothing. - */ export function toLegacyPhase(state: AgentActivityState): AgentPhase | undefined { const { lifecycle, turn, lastTurn } = state; diff --git a/packages/agent-gateway/src/services/messages/messageHistory.ts b/packages/agent-gateway/src/services/messages/messageHistory.ts index 61ea0e2b8..d35f108a0 100644 --- a/packages/agent-gateway/src/services/messages/messageHistory.ts +++ b/packages/agent-gateway/src/services/messages/messageHistory.ts @@ -22,7 +22,6 @@ import { toProtocolMessage } from './messageProjection'; const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 100; -/** Sentinel — the route maps it to 40401. */ export class SessionNotFoundError extends Error { readonly sessionId: string; constructor(sessionId: string) { @@ -32,7 +31,6 @@ export class SessionNotFoundError extends Error { } } -/** Sentinel — the route maps it to 40403. */ export class MessageNotFoundError extends Error { readonly sessionId: string; readonly messageId: string; @@ -116,13 +114,6 @@ async function loadMessages(core: Scope, sessionId: string): Promise return loadMessageHistory(core, agent, sessionId, summary.createdAt); } -/** - * One agent's full, ascending, projected message history: the persisted - * journal (flushed first) folded by the transcript reducer, the unflushed - * live tail merged in, blob references rehydrated, and timestamps clamped - * strictly increasing. Shared by the `messages` routes and the `snapshot` - * route so all history-serving surfaces agree. - */ export async function loadMessageHistory( core: Scope, agent: IAgentScopeHandle, diff --git a/packages/agent-gateway/src/services/messages/messageProjection.ts b/packages/agent-gateway/src/services/messages/messageProjection.ts index 0b9af2d5e..eecdbb5b9 100644 --- a/packages/agent-gateway/src/services/messages/messageProjection.ts +++ b/packages/agent-gateway/src/services/messages/messageProjection.ts @@ -90,14 +90,6 @@ function buildProtocolContent(msg: ContextMessage): MessageContent[] { return base; } -/** - * Prompt content (engine kosong parts) → the v1 wire `messageContentSchema` - * shape. Shared by every prompt-queue surface — the REST prompt list, the - * `prompt.steered` session event, and the transcript prompt entity — so a - * self-contained daemon-ref media part projects back to - * `{ kind: 'session_media', file_id }`: neither the transient App upload nor - * the internal `pythinker-file://` URL becomes the stored read-model contract. - */ export function projectPromptContentParts(content: readonly ContentPart[]): MessageContent[] { const parts: MessageContent[] = []; for (const part of content) { diff --git a/packages/agent-gateway/src/services/telemetry.ts b/packages/agent-gateway/src/services/telemetry.ts index 736c29c01..ff6b65843 100644 --- a/packages/agent-gateway/src/services/telemetry.ts +++ b/packages/agent-gateway/src/services/telemetry.ts @@ -17,7 +17,6 @@ const TELEMETRY_DISABLE_ENV_VALUES = new Set(['1', 'true', 't', 'yes', 'y']); const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 3_000; export interface ServerTelemetry { - /** Present only when telemetry is enabled by both config and environment. */ readonly appender?: CloudAppender; readonly registration?: IDisposable; } diff --git a/packages/agent-gateway/src/services/transcript/coreBinding.ts b/packages/agent-gateway/src/services/transcript/coreBinding.ts index 03d7cfc8d..f0eb964aa 100644 --- a/packages/agent-gateway/src/services/transcript/coreBinding.ts +++ b/packages/agent-gateway/src/services/transcript/coreBinding.ts @@ -22,24 +22,11 @@ import { type ProjectorInteraction, } from './coreEventMap'; -/** Minimal warn sink (matches `JournalLogger`). */ export interface TranscriptBindingLogger { warn(obj: unknown, msg: string): void; } -/** The live binding plus its deferred seeding hook. */ export interface TranscriptBinding extends IDisposable { - /** - * Announce interactions that were already pending at bind time. - * Deliberately NOT run during bind: the store (and the projector's tool - * map) is empty until the initial history backfill lands, so an early - * announce misplaces the frame into a synthetic step and loses the - * resolve-time `approvalId` back-link. The service calls it after the - * initial backfill for the main agent, and after each agent's on-demand - * backfill for that agent's interactions — pass `agentId` to seed only the - * pending interactions routed to that agent (a subagent's pending must not be placed - * before its own history is replayed). - */ seedPendingInteractions(agentId?: string): void; } diff --git a/packages/agent-gateway/src/services/transcript/coreEventMap.ts b/packages/agent-gateway/src/services/transcript/coreEventMap.ts index baabf5be7..1011e7c4e 100644 --- a/packages/agent-gateway/src/services/transcript/coreEventMap.ts +++ b/packages/agent-gateway/src/services/transcript/coreEventMap.ts @@ -82,7 +82,6 @@ import { projectPromptContentParts } from '../messages/messageProjection'; export interface ProjectorInteraction { readonly id: string; readonly kind: 'approval' | 'question'; - /** In-process `ApprovalRequest` / `QuestionRequest`, passed through as-is. */ readonly payload: unknown; readonly origin: { readonly agentId?: string; readonly turnId?: number }; } @@ -141,14 +140,6 @@ export type ProjectorBusEvent = | ({ readonly type: 'error' } & AgentErrorEvent) | ({ readonly type: 'warning' } & WarningIssued); -/** - * The v1-wire `prompt.submitted` shape (agent-gateway `protocol/events-zod.ts`). - * The v2 bus never publishes it (see `agent/prompt/promptService.ts`, which - * emits only completed / aborted / steered), so it is declared here rather - * than derived from `DomainEvent`; `map` accepts it so an edge that learns - * about a submission (REST prompt path, a future engine event) can project it - * through the same entry point. - */ export interface ProjectorPromptSubmittedEvent { readonly type: 'prompt.submitted'; readonly promptId: string; @@ -158,31 +149,17 @@ export interface ProjectorPromptSubmittedEvent { readonly createdAt: string; } -/** - * Read access to one step's current frames (the producer store). Used for - * mid-stream attach adoption — see `adoptStreamFrame`. - */ export type ProjectorFrameLookup = ( turnId: string, stepId: string, ) => readonly TranscriptFrame[] | undefined; -/** - * Locate a tool frame by its toolCallId across the producer store. Used for - * mid-bind result adoption — see `adoptToolFrame`. - */ export type ProjectorToolFrameLookup = (toolCallId: string) => ToolFrameRecord | undefined; -/** - * The engine-reported current step ordinal for a turn (the activity view). - * Used to place deltas correctly when the projector attached after - * `turn.step.started` for a later step — see `ensureStep`. - */ export type ProjectorStepOrdinalLookup = (turnId: string) => number | undefined; export type ProjectorTurnLookup = (turnId: string) => TurnHeader | undefined; -/** Optional producer-store lookups that let the projector adopt seeded state. */ export interface ProjectorLookups { readonly stepFrames?: ProjectorFrameLookup; readonly toolFrame?: ProjectorToolFrameLookup; @@ -203,31 +180,18 @@ export interface ToolFrameRecord { } export class AgentTranscriptProjector { - /** Latest header of the in-flight (or most recent) turn; kept whole so terminal upserts preserve `origin` / `startedAt` by reference. */ private currentTurn: TurnHeader | undefined; private currentStep: StepHeader | undefined; private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; - /** turnId → highest step ordinal seen (engine-reported placement hint). */ private readonly stepOrdinals = new Map(); private frameOrdinal = 0; private openText: OpenTextFrame | undefined; private openThinking: OpenTextFrame | undefined; private readonly toolFrames = new Map(); - /** Last whole TranscriptTask emitted per task id (`task.upsert` replaces, so the local copy must carry `outputTail` forward). */ private readonly tasks = new Map(); - /** shell `commandId` → transcript `taskId` (`shell.output` is keyed by command id only). */ private readonly shellTasks = new Map(); - /** subagent agent id → registered task id, for Agent-tool runs whose spawned - carried the registration (`taskId`): the task row keys by the task id so - `/tasks/{id}` actions resolve, and lifecycle events fold back to it. */ private readonly subagentTaskIds = new Map(); - /** Pre-seed the association and the row for a task registered before - attach: a foreground Agent run emits no `task.started` at all, so - without this a late-bound projector never learns the mapping, shows no - cancellable row, and lets the terminal event invent foreground-wrong - defaults. Only in-flight tasks seed (a terminal one has no lifecycle - left to fold). */ seedSubagentTask(info: { readonly taskId: string; readonly agentId: string; @@ -251,14 +215,10 @@ export class AgentTranscriptProjector { })); return [{ op: 'task.upsert', task }]; } - /** interaction id → the pending entity as last emitted (resolve spreads it). */ private readonly interactions = new Map(); - /** promptId → the prompt queue entity as last emitted (`prompt.upsert` replaces). */ private readonly prompts = new Map(); - /** turnId → step usages reported so far; folded into the turn header at `turn.ended`. */ private readonly stepUsageByTurn = new Map(); private markerSeq = 0; - /** Tracked from `agent.status.updated` planMode slices; gates the badge refinement on `plan.revision`. */ private planModeActive = false; constructor( @@ -442,15 +402,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * Fold this turn's accumulated step usages into the turn header's - * `TranscriptUsage` and drop the accumulator. Step usages are the engine's - * four-component `TokenUsage`; the header maps them to the render vocabulary - * (`inputTokens = inputOther + inputCacheCreation`, - * `cachedTokens = inputCacheRead`, `outputTokens = output`). A turn whose - * steps all reported no usage gets no `usage` at all (the components have - * no data either way — the wire never omits a single component). - */ private takeTurnUsage(turnId: string): TranscriptUsage | undefined { const usages = this.stepUsageByTurn.get(turnId); this.stepUsageByTurn.delete(turnId); @@ -580,12 +531,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * `turn.step.retrying` — a claimed provider failure is being retried on the - * same step. The step stays 'running' with the retry detail on the header; - * the terminal step upsert simply carries no `retry`, which clears it - * (step.upsert replaces the whole header). - */ private onStepRetrying(event: { turnId: number; step: number; @@ -658,20 +603,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * Mid-stream attach adoption. When the projector starts streaming a step it - * has never seen, the history backfill may already have seeded that step's - * stream frame with the text persisted so far (the in-flight turn's deltas - * are persisted upstream). Opening a fresh frame here would emit an empty - * `frame.upsert` that clobbers the seeded text, followed by offset-0 - * appends that cannot land past it — corrupting the live transcript until - * the next cold rebuild. Instead adopt the seeded frame: continue its id - * and offset (the persisted text is a prefix of the same stream), and - * advance `frameOrdinal` past the step's existing `.fN` frames so later - * frames cannot collide. Known limitation: deltas observed between bind - * and the backfill landing still open a fresh frame (the backfill's later - * upsert then replaces it wholesale). - */ private adoptStreamFrame( turnId: string, stepId: string, @@ -698,7 +629,6 @@ export class AgentTranscriptProjector { return undefined; } - /** Re-emit every open text/thinking frame with its full text (the 'block'-grade convergence point). */ private flushOpenFrames(ops: TranscriptOperation[]): void { const step = this.currentStep; for (const open of [this.openText, this.openThinking]) { @@ -717,14 +647,6 @@ export class AgentTranscriptProjector { this.openThinking = undefined; } - /** - * Resolve the step a content event belongs to. When the projector missed - * `turn.step.started` (mid-stream attach), prefer the engine-reported - * active step from the activity view; then the latest step this projector - * saw; only then the `t.1` fallback (the store skeleton-fills anything - * still missing). Without the lookup a late attach at step ≥ 2 would - * stream into the wrong step. - */ private ensureStep(turnId: string, ops: TranscriptOperation[]): StepHeader { if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { return this.currentStep; @@ -743,13 +665,6 @@ export class AgentTranscriptProjector { return this.currentStep; } - /** - * `tool.call.delta` — raw argument streaming. The deltas accumulate into the - * frame's `inputText` (the verbatim counterpart of the parsed `input`). A - * delta can arrive before `tool.call.started` (the stream reports arguments - * as they generate): the frame is then created here, and the later started - * event fills in name/input/display while keeping the accumulated text. - */ private onToolCallDelta(event: { turnId: number; toolCallId: string; @@ -783,10 +698,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * `tool.progress` — the newest execution update overwrites the frame's - * `progress` (whole-frame upsert, as for every tool frame mutation). - */ private onToolProgress(event: { toolCallId: string; update: ToolFrameProgress; @@ -863,13 +774,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * Mid-bind adoption: the transcript may have attached after `tool.call.started` - * (the backfill seeded the frame from the persisted assistant toolCalls) but - * before `tool.result`. This projector's map is empty then and the result - * would be dropped; adopt the seeded frame so the result lands where a live - * observer put it. - */ private adoptToolFrame(toolCallId: string): ToolFrameRecord | undefined { const hit = this.lookups?.toolFrame?.(toolCallId); if (hit === undefined) return undefined; @@ -877,14 +781,6 @@ export class AgentTranscriptProjector { return hit; } - /** - * `task.notified` — a background task's completion notification. Mid-turn - * the engine injects the notification message into the running turn's - * context, so it surfaces as a user input frame inside the open step, - * linked to the task entity (`text.taskId`). When no step is open the - * notification opens a fresh turn with `origin.kind === 'task'` instead - * (the `turn.started` path owns that case). - */ private onTaskNotified(event: { notificationType: string; title: string; @@ -983,14 +879,6 @@ export class AgentTranscriptProjector { ]; } - /** - * Resolve the transcript task for a `shell.*` event: the id learned at - * `shell.started`, else the id the event carries (mid-command attach), else - * a synthetic per-command id. The fallback matters for commands that fail - * before `onForegroundTaskStart` runs (Bash validation/spawn errors): their - * events all arrive taskId-less, and dropping them would lose the stderr - * and the terminal state of a command that did run. - */ private shellTaskId(event: { commandId: string; taskId?: string }): string { const taskId = this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`; this.shellTasks.set(event.commandId, taskId); @@ -1033,12 +921,6 @@ export class AgentTranscriptProjector { return ops; } - /** - * `shell.completed` — terminal state for a foreground `!` command (the task - * lifecycle never reports foreground tasks, so without this the transcript - * task would stay 'running' forever). Detached runs report through - * `task.*` instead. - */ private onShellCompleted(event: { commandId: string; taskId?: string; @@ -1298,25 +1180,12 @@ export class AgentTranscriptProjector { return ops; } - /** - * `agent.activity.updated` — the engine's folded activity view. Projected - * through `toLegacyPhase` (the same v1 phase projection the WS edge uses — - * see `sessionEventBroadcaster.ts`) into `meta.agent.phase`; `disposing` / - * `disposed` states map to `undefined` and emit nothing, as at the edge. - */ private onAgentActivityUpdated(event: AgentActivityUpdatedEvent): TranscriptOperation[] { const phase = toLegacyPhase(event); if (phase === undefined) return []; return [{ op: 'meta.merge', meta: { agent: { phase } } }]; } - /** - * `plan.revision` — a plan content version was offloaded on review - * submission. Always lands as a 'plan.revision' timeline marker (it stays - * after plan mode exits); while plan mode is still active it also refines - * the plan badge with the revision reference (`exit`/`cancel` later clear - * the badge via the `planMode: false` slice, as before). - */ private onPlanRevision(event: PlanRevisionEvent): TranscriptOperation[] { const ops: TranscriptOperation[] = [this.markerOp('plan.revision', restOf(event))]; if (this.planModeActive) { @@ -1410,14 +1279,6 @@ export class AgentTranscriptProjector { return [{ op: 'prompt.upsert', prompt }]; } - /** - * `prompt.steered` — queued prompts were merged into the running prompt's - * turn (`AgentPromptService.steer`). The active prompt keeps running with - * the merged content and the steer timestamp; the absorbed prompts leave - * the queue — the engine marks them 'steered' and later settles them with - * the active prompt's outcome, so the transcript settles them as - * 'completed' (their content was delivered, not aborted). - */ private onPromptSteered(event: PromptSteeredEvent): TranscriptOperation[] { const ops: TranscriptOperation[] = []; const active = this.upsertPrompt(event.activePromptId, (prev) => ({ @@ -1454,14 +1315,6 @@ export class AgentTranscriptProjector { return prompt; } - /** - * `requested` — entity-only emission: the global interaction entity - * (`interaction.upsert`), addressed by id, pagination-proof, visible at - * 'turn' grade. Interactions never appear as inline step frames. The - * entity's `toolCallId` (the timeline anchor) is read from the payload - * when present and omitted otherwise; an unanchored interaction renders - * floating in consumers. - */ mapInteractionRequested(interaction: ProjectorInteraction): TranscriptOperation[] { const payload = interaction.payload as { toolCallId?: unknown }; const toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : undefined; @@ -1476,11 +1329,6 @@ export class AgentTranscriptProjector { return [{ op: 'interaction.upsert', interaction: entity }]; } - /** - * `resolved` — terminal state plus the raw engine response on the entity; - * when the linked tool call is known, re-emit its frame with the - * `approvalId` back-link. - */ mapInteractionResolved(id: string, response: unknown): TranscriptOperation[] { const record = this.interactions.get(id); if (record === undefined) return []; diff --git a/packages/agent-gateway/src/services/transcript/transcriptService.ts b/packages/agent-gateway/src/services/transcript/transcriptService.ts index 640d5db03..dc823ba17 100644 --- a/packages/agent-gateway/src/services/transcript/transcriptService.ts +++ b/packages/agent-gateway/src/services/transcript/transcriptService.ts @@ -75,14 +75,11 @@ interface AgentOpsJournal { batches: { seq: number; ops: TranscriptOperation[] }[]; } -/** Retained op batches per agent; older batches evict (catch-up turns incomplete). */ export const TRANSCRIPT_OPS_JOURNAL_CAPACITY = 2000; -/** Catch-up view over one agent's journal: batches with seq > sinceSeq, oldest first. */ export interface TranscriptOpsCatchup { readonly batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[]; readonly latestSeq: number; - /** false when the journal no longer reaches back to sinceSeq — the caller must do a full refresh. */ readonly complete: boolean; } @@ -92,7 +89,6 @@ export class TranscriptService { string, Set<(event: TranscriptChangeEvent, seq: number) => void> >(); - /** Debounced post-turn heals: `${sessionId}:${agentId}` → pending ordinals + timer. */ private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); constructor(private readonly deps: TranscriptServiceDeps) { @@ -108,10 +104,6 @@ export class TranscriptService { }); } - /** - * Get (or create + bind) the transcript store for a session that is live in - * this process. Returns `undefined` when the session is not in memory. - */ forSessionLive(sessionId: string): TranscriptStore | undefined { const existing = this.live.get(sessionId); if (existing !== undefined) { @@ -150,23 +142,10 @@ export class TranscriptService { return store; } - /** - * Resolves when the session's initial history backfill has landed (or - * immediately when the session has no live store). Full-read consumers - * (REST route, WS subscribe) await this so the first answer carries the - * established main-agent transcript. - */ async whenReady(sessionId: string): Promise { await this.live.get(sessionId)?.ready; } - /** - * Ensure one agent's persisted history is replayed into the live store - * (idempotent per agent; the main agent is already covered by the initial - * backfill). Awaited by full-read consumers for the `agent_id` they serve, - * so any agent's transcript — including subagents that are not - * materialized in this process — comes back established. - */ async ensureAgentHistory(sessionId: string, agentId: string): Promise { if (agentId === MAIN_AGENT_ID) return this.whenReady(sessionId); const entry = this.live.get(sessionId); @@ -183,7 +162,6 @@ export class TranscriptService { } } - /** Initial backfill: main-agent history + the full roster from session metadata. */ private async backfillMain(sessionId: string, store: TranscriptStore): Promise { await this.backfillAgent(sessionId, store, MAIN_AGENT_ID); if (this.live.get(sessionId)?.store !== store) return; @@ -197,13 +175,6 @@ export class TranscriptService { } } - /** - * Replay one agent's persisted wire records into its transcript. Everything - * is an idempotent upsert (never `reset`), so live ops arriving while the - * records are read from disk survive the merge; turn ordinals assigned by - * the rebuild are 0-based like the engine's, so future live turns continue - * without colliding. - */ private async backfillAgent(sessionId: string, store: TranscriptStore, agentId: string): Promise { let snapshot: AgentTranscriptSnapshot | undefined; try { @@ -246,15 +217,6 @@ export class TranscriptService { } } - /** - * Subscribe to the session's mapped-op stream (one shared subscription per - * session — the broadcaster fans grades out against it). These are the - * projector-mapped ops, not the store's accepted ops; see - * `bindSessionTranscript` for why. Each batch carries its per-agent seq - * (consecutive from 1; 0 only when the session has no live entry, which a - * registered listener cannot observe). Returns `undefined` when the session - * is not live (caller skips streaming for cold sessions). - */ onSessionOps( sessionId: string, listener: (event: TranscriptChangeEvent, seq: number) => void, @@ -288,12 +250,6 @@ export class TranscriptService { } } - /** - * Append one dispatched batch to its agent's journal and assign the next - * consecutive seq. Journaling happens before the fan-out (and regardless of - * listeners), so the watermark always covers every dispatched batch. Returns - * 0 when the session has no live entry — the journal dies with the store. - */ private journalOps(sessionId: string, event: TranscriptChangeEvent): number { const entry = this.live.get(sessionId); if (entry === undefined) return 0; @@ -308,25 +264,11 @@ export class TranscriptService { return seq; } - /** - * Watermark for one agent: the seq of its latest dispatched op batch (0 when - * nothing was dispatched — or the session is not live, cold sessions having - * no journal). - */ getSeqWatermark(sessionId: string, agentId: string): number { const journal = this.live.get(sessionId)?.opsJournals.get(agentId); return journal === undefined ? 0 : journal.nextSeq - 1; } - /** - * Point-to-point catch-up: the journaled batches with seq > `sinceSeq`, - * oldest first. `complete` is true only when every batch in - * (sinceSeq, latestSeq] is retained — a sinceSeq ahead of the watermark - * (stale cursor from a dead journal incarnation) or one the bounded journal - * has already evicted yields `complete: false`, telling the caller to fall - * back to a full refresh. Returns `undefined` when the session is not live - * (cold sessions have no journal). - */ getOpsSince( sessionId: string, agentId: string, @@ -342,11 +284,6 @@ export class TranscriptService { return { batches, latestSeq, complete }; } - /** - * Live (projector-mapped) op batches: fan out, then watch for terminal - * turns to heal. Backfill batches go through `dispatchOps` directly so a - * replayed history cannot retrigger heals. - */ private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { this.dispatchOps(sessionId, event); for (const op of event.ops) { @@ -373,14 +310,6 @@ export class TranscriptService { this.healTimers.set(key, { ordinals, timer }); } - /** - * A backfill rebuilds every turn as 'completed' — the cold grouping cannot - * see in-flight work. When the agent's loop is actually mid-turn, re-assert - * the active turn's header as 'running' AFTER the snapshot ops (its cold - * 'completed' header would otherwise win, even over a live running header - * the projector already wrote). Live header fields win, then the - * snapshot's. Returns `undefined` only when the loop is idle. - */ private liveTurnOverlay( sessionId: string, agentId: string, @@ -450,14 +379,6 @@ export class TranscriptService { return ops; } - /** - * Re-read the agent's persisted history and merge the ended turn(s) back - * into the live store. The projector attaches to the bus at bind time, so - * text streamed (and persisted) before that is missing from its frames; by - * the time a turn ends, its records are complete on disk. The merge is - * deliberately conservative (`healTurnOps`): live state wins everywhere - * except the one regression being healed — truncated text/thinking frames. - */ private async healEndedTurns( sessionId: string, agentId: string, @@ -498,13 +419,6 @@ export class TranscriptService { this.dispatchOps(sessionId, { agentId, ops }); } - /** - * Roster for a cold session, read from the persisted session metadata - * (`/state.json`) and mapped like the live seeding - * (`descriptorFromMeta`). Returns `undefined` when the session is unknown - * to the index; an unreadable or missing metadata file yields an empty - * roster (best-effort — transcripts work without descriptors). - */ async readColdRoster(sessionId: string): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; @@ -524,12 +438,6 @@ export class TranscriptService { ); } - /** - * Rebuild one agent's transcript snapshot for a cold session from its - * persisted wire records. Returns `undefined` when the session is unknown to - * the index; a known session without wire records for the agent yields an - * empty snapshot. - */ async readColdSnapshot( sessionId: string, agentId: string = MAIN_AGENT_ID, @@ -633,7 +541,6 @@ export class TranscriptService { return this.deps.core.accessor.get(ISessionManager).get(owner) === undefined; } - /** Dispose the live store + binding for a session (session closed / server shutdown). */ dropSession(sessionId: string): void { this.opsListeners.delete(sessionId); for (const [key, pending] of this.healTimers) { @@ -649,24 +556,6 @@ export class TranscriptService { } } -/** - * Flatten a snapshot into idempotent upsert ops (turn/step/frame upserts, - * standalone items, tasks, meta). Deliberately never a `reset`: upserts merge - * by id and keep ordinal order, so the backfill cannot clobber live ops that - * landed while the records were being read. Global attachment entities flatten - * too — without them a backfilled turn's `attachmentIds` would dangle. - * - * Standalone items (markers / taskrefs) carry a `beforeTurn` placement anchor: - * the reducer's standalone path is append-only, so without an anchor a - * historical marker replayed after live turns arrived would land past them. - * The anchor is the ordinal of the snapshot turn directly following the item - * (trailing items anchor past the last snapshot turn, which is where the - * engine's next live turn lands); a turn-anchored insert places the item - * before the first turn with `ordinal >= beforeTurn`. - * - * `turnOps` customizes the per-turn flattening (the backfill passes a - * live-first merge; the default flattens wholesale for cold reads). - */ export function snapshotToOps( snapshot: AgentTranscriptSnapshot, turnOps: (turn: TranscriptTurn) => TranscriptOperation[] = snapshotTurnOps, @@ -704,7 +593,6 @@ export function snapshotToOps( return ops; } -/** One snapshot turn flattened wholesale (the cold / unseen-turn path). */ export function snapshotTurnOps(turn: TranscriptTurn): TranscriptOperation[] { const ops: TranscriptOperation[] = []; const { steps, ...header } = turn; @@ -740,28 +628,6 @@ function supersededColdAttachmentIds( return superseded; } -/** - * Merge one persisted (snapshot) turn back into the live store after the turn - * ended — the post-turn heal for mid-turn attaches: - * - turn the live store never saw: taken wholesale; - * - header: the snapshot is authoritative for origin/prompt (it reads the - * persisted user message, which a mid-turn-attached projector missed); - * the live header wins on state, timestamps, and attachment ids (its - * `{turnId}.att` entities are already projected — swapping in the - * snapshot's cold `att_` ids would churn the references and orphan - * the live entities); - * - steps the live turn never saw: taken wholesale from the snapshot; - * - existing steps: text/thinking frames are re-emitted only when the - * persisted text is longer and the kind matches (a fresh live frame may - * still be ahead of a lagging flush); tool frames are re-emitted when - * the live step lacks the frame or the live frame lacks the outcome the - * persisted one carries (a tool.result dropped in the attach race is - * otherwise unrecoverable until a cold rebuild) — live-only extras - * (display / agentRefs / approvalId) are preserved on the emitted frame; - * - interactions are never re-emitted: they are global entities (not step - * content), are not persisted as context messages, and the live kernel - * bridge is always richer. - */ export function healTurnOps( snapshotTurn: TranscriptTurn, liveTurn: TranscriptTurn | undefined, diff --git a/packages/agent-gateway/src/start.ts b/packages/agent-gateway/src/start.ts index eccc661fa..a76d3fb2f 100644 --- a/packages/agent-gateway/src/start.ts +++ b/packages/agent-gateway/src/start.ts @@ -93,9 +93,7 @@ import { createTokenStore } from './services/auth/tokenStore'; import { drainGlobalSearchDisposals, IGlobalSearchService } from './search/searchService'; export interface ServerHostIdentity extends PythinkerHostIdentity { - /** Fills the `${product_name}` slot in the base system prompt. Defaults render the CLI text. */ readonly displayName?: string; - /** Replaces the `${reply_style_guide}` block in the base system prompt. */ readonly replyStyleGuide?: string; } @@ -103,25 +101,9 @@ export interface ServerStartOptions { readonly host?: string; readonly port?: number; readonly homeDir?: string; - /** - * Environment bag handed to the engine bootstrap (`IBootstrapService.getEnv`). - * Defaults to `process.env`; hosts that need to override engine-level env - * reads (e.g. an embedded server pinning `PYTHINKER_CODE_REGION_MARKER=off`) - * pass a merged bag here instead of mutating the host process's env, which - * would leak the override into every child process the host spawns. - */ readonly env?: NodeJS.ProcessEnv; - /** - * Plugin marketplace catalog URL for `GET /api/v1/plugins/marketplace`. - * Defaults to the `PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL` env var. - */ readonly pluginMarketplaceUrl?: string; readonly configPath?: string; - /** - * Override the instance-registry directory — used in tests that need the - * registry OUTSIDE `homeDir` (e.g. folder-picker fixtures browsing the home - * dir). Defaults to `/server/instances`. - */ readonly instancesDir?: string; readonly logLevel?: ServerLogLevel; readonly logger?: ServerLogger; @@ -135,58 +117,13 @@ export interface ServerStartOptions { readonly allowRemoteTerminals?: boolean; readonly authTokenService?: IAuthTokenService; readonly disableAuth?: boolean; - /** - * Custom browser tab title for this web UI instance (the CLI's - * `--web-title`). Surfaced as `web_title` in `GET /api/v1/meta` so the web - * UI can distinguish multiple instances on different machines. Instance-level - * and frozen at boot; omit to let the UI fall back to ` | Pythinker Code`. - */ readonly webTitle?: string; - /** - * Optional *additional* credential accepted on the RPC surface (debug REST + - * WebSocket) alongside the persistent bearer token. Never required and never - * the only gate: the persistent token always protects the RPC surface. Leave - * unset unless a second, distinct RPC credential is genuinely needed. - */ readonly rpcToken?: string; - /** Extra scope seeds applied at bootstrap (e.g. a host-provided `ISessionModelResolver`). */ readonly seeds?: ScopeSeed; - /** - * Identity of the host product embedding the server: feeds the engine's - * `bootstrap()` client identity, the default outbound request headers - * (User-Agent via `createPythinkerDefaultHeaders`), and the session - * export manifest. Applied to every agent and request the server hosts — - * required, so every host states its own product name, version, and - * platform explicitly. - */ readonly hostIdentity: ServerHostIdentity; - /** - * Explicit skill directories for this process (v1's SDK `skillDirs`): when - * non-empty, default user / project skill discovery is skipped and these - * directories serve as the user skill source for every session. Applied to - * all sessions the server hosts — for embedding hosts, not per-session use. - */ readonly skillDirs?: readonly string[]; - /** - * Directory of the built Pythinker web UI (`dist-web`). When set, `GET /` and the - * `/*` SPA fallback serve these assets (auth-exempt, matching v1). Omit to run - * the API server without the web UI. - */ readonly webAssetsDir?: string; - /** - * Engine version, reported as `server_version` (GET /api/v1/meta), in the - * OpenAPI document, and in the lock / instance registry. Defaults to - * agent-gateway's own package version; the host product version travels in - * `hostIdentity.version` instead. - */ readonly serverVersion?: string; - /** - * Opt-in cloud telemetry for the engine's `ITelemetryService` events: when - * true, a `CloudAppender` is attached at startup (still gated by the config - * `telemetry` toggle) and flushed on close. Defaults to false so tests and - * embedding hosts that wire their own telemetry never post to the real - * endpoint unintentionally; the CLI's `pythinker web` host passes true. - */ readonly telemetry?: boolean; } @@ -653,41 +590,16 @@ export async function startServer(opts: ServerStartOptions): Promise Promise; readonly host: string; readonly port: number; readonly logger: ServerLogger; - /** Override the retry cap — used by tests to keep the walk short. */ readonly maxRetries?: number; } -/** - * Bind the listener, retrying on `port + 1` when the port is held. - * - * Why this is the right layer: there is no single-instance lock — every - * agent-gateway registers itself under `/server/instances/` instead, so a - * busy port may be a sibling pythinker instance. The `port + 1` walk then serves - * as the multi-instance coexistence mechanism (the second instance lands on - * the next free port), and a third-party listener gets the same "port busy ⇒ - * +1" policy as v1. - * - * Port `0` (OS-assigned ephemeral) is never retried: the kernel already picks a - * free port, so `EADDRINUSE` cannot arise from a specific-port conflict. - */ export async function listenWithPortRetry( opts: ListenWithPortRetryOptions, ): Promise<{ address: string; port: number }> { diff --git a/packages/agent-gateway/src/transport/channel.ts b/packages/agent-gateway/src/transport/channel.ts index 9159cdea6..b99d3a1f9 100644 --- a/packages/agent-gateway/src/transport/channel.ts +++ b/packages/agent-gateway/src/transport/channel.ts @@ -1,6 +1,5 @@ export type ScopeKind = 'core' | 'session' | 'agent'; -/** The client-facing channel contract (request/response + future events). */ export interface IChannel { call(command: string, arg?: unknown): Promise; listen(event: string, arg?: unknown): unknown; diff --git a/packages/agent-gateway/src/transport/channelRegistry.ts b/packages/agent-gateway/src/transport/channelRegistry.ts index a343fe6ef..903b7f00e 100644 --- a/packages/agent-gateway/src/transport/channelRegistry.ts +++ b/packages/agent-gateway/src/transport/channelRegistry.ts @@ -9,30 +9,15 @@ import type { Scope, ScopedEntry, ServiceIdentifier } from '@pymodel/agent-core- export interface ChannelMethodDescriptor { readonly name: string; - /** `method` is a callable; `property` is a getter readable with no args. */ readonly kind: 'method' | 'property'; - /** Declared parameter count (`Function.length`) — a UI hint, not a schema. */ readonly arity: number; - /** - * Declared parameter list as written in source (e.g. `title`, - * `{ workspaceId, limit }`), parsed from `Function#toString`. Names only — - * types are erased at runtime. Empty for getters and zero-arg methods. - * Relies on running from source; a minified bundle would degrade the names. - */ readonly params: string; } export interface ChannelDescriptor { - /** Decorator id / wire channel name, e.g. `sessionMetadata`. */ readonly name: string; - /** - * Registration scope — the minimal scope at which the channel resolves. - * Derived from the scoped DI registry. - */ readonly scope: 'app' | 'session' | 'agent'; - /** Domain tag recorded at `registerScopedService`. */ readonly domain: string; - /** Public prototype members, sorted — events are instance properties and never appear. */ readonly methods: readonly ChannelMethodDescriptor[]; } @@ -62,7 +47,6 @@ function scopedServiceNameIndex(): Map> { return serviceNameIndex; } -/** Resolve a wire name to its `ServiceIdentifier` anywhere in the DI registry. */ export function resolveAnyScopedServiceId( core: Scope, name: string, @@ -119,11 +103,6 @@ function describeMethods( return [...methods.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } -/** - * Describe EVERY registered scoped Service — served by - * `GET /api/v1/debug/channels` so dev tooling (pythinker-inspect) can load the - * full protocol surface 1:1. - */ export function describeAllChannels(): readonly ChannelDescriptor[] { const byName = new Map(); for (const scope of [LifecycleScope.App, LifecycleScope.Session, LifecycleScope.Agent]) { diff --git a/packages/agent-gateway/src/transport/dispatcher.ts b/packages/agent-gateway/src/transport/dispatcher.ts index bb78e6c43..33e5a68c4 100644 --- a/packages/agent-gateway/src/transport/dispatcher.ts +++ b/packages/agent-gateway/src/transport/dispatcher.ts @@ -13,21 +13,8 @@ import { resolveAnyScopedServiceId } from './channelRegistry'; import { assertSerializable } from './errors'; import { MAIN_AGENT_ID, ensureMainAgent } from './mainAgent'; -/** - * Channel name → identifier resolution used to gate which Services are - * reachable. The single RPC surface (`/api/v1/debug`) resolves against the - * full scoped DI registry (default). - */ export type ChannelLookup = (name: string) => ServiceIdentifier | undefined; -/** - * Resolve the scope a request targets. Throws `Error2` when the referenced - * session or agent does not exist — `session.not_found` for a missing session, - * `agent.not_found` when the session exists but the agent scope is not - * materialized (e.g. a subagent created before the last server restart or - * session close: its metadata registry entry and wire log persist, but - * `resume` only re-materializes the main agent). - */ export async function resolveScope( core: Scope, scopeKind: ScopeKind, @@ -64,11 +51,6 @@ export async function resolveScope( } } -/** - * Dispatch one call. Throws `Error2` for expected failures (unknown service, - * scope not found, service not in scope, method missing); the route maps them - * to the envelope. Unexpected errors propagate and become `50001`. - */ export async function resolveService( core: Scope, scopeKind: ScopeKind, diff --git a/packages/agent-gateway/src/transport/errors.ts b/packages/agent-gateway/src/transport/errors.ts index a5c0a22da..fffdc5f88 100644 --- a/packages/agent-gateway/src/transport/errors.ts +++ b/packages/agent-gateway/src/transport/errors.ts @@ -3,7 +3,6 @@ import { ErrorCodes, Error2 } from '@pymodel/agent-core-v2'; import { errEnvelope } from '../protocol/envelope'; import { ErrorCode } from '../protocol/error-codes'; -/** Thrown by {@link withTimeout} when a call exceeds its deadline. */ export class TimeoutError extends Error { constructor(readonly ms: number) { super(`call timed out after ${ms}ms`); @@ -11,7 +10,6 @@ export class TimeoutError extends Error { } } -/** Race a promise against a deadline. */ export function withTimeout(promise: Promise, ms: number): Promise { if (ms <= 0) return promise; let timer: ReturnType | undefined; @@ -52,11 +50,6 @@ const PYTHINKER_TO_PROTOCOL: Record = { [ErrorCodes.STORAGE_LOCKED]: ErrorCode.PERSISTENCE_FAILURE, }; -/** - * Map an internal error to the project envelope. `Error2` keeps its coded - * mapping; everything else becomes `50001`. Stack traces are intentionally not - * surfaced. - */ export function mapError(err: unknown, requestId: string): ReturnType { if (err instanceof Error2) { const code = PYTHINKER_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR; @@ -73,7 +66,6 @@ export function mapError(err: unknown, requestId: string): ReturnType readonly ChannelDescriptor[]; } -/** - * Mount the reflection dispatcher under `basePath` (e.g. `/debug` inside the - * prefixed `/api/v1` plugin): the three scope routes plus - * `GET {basePath}/channels` for introspection. `channels` is a single segment, - * so it cannot collide with `:service/:method`. - */ export function registerServiceDispatcherRoutes( app: RouteHost, core: Scope, diff --git a/packages/agent-gateway/src/transport/ws/connectionRegistry.ts b/packages/agent-gateway/src/transport/ws/connectionRegistry.ts index 790ebac11..6242b276d 100644 --- a/packages/agent-gateway/src/transport/ws/connectionRegistry.ts +++ b/packages/agent-gateway/src/transport/ws/connectionRegistry.ts @@ -9,17 +9,11 @@ export interface ConnectionLike { } export interface IConnectionRegistry { - /** Insert a freshly-accepted connection. */ add(conn: ConnectionLike): void; - /** Remove a closed connection. Idempotent. */ remove(connId: string): void; - /** Look up by id. */ get(connId: string): ConnectionLike | undefined; - /** Iterate all currently-attached connections. */ values(): Iterable; - /** Close every attached connection (used on shutdown). */ closeAll(reason?: string): void; - /** Number of currently-attached connections. */ size(): number; } diff --git a/packages/agent-gateway/src/transport/ws/v1/events.ts b/packages/agent-gateway/src/transport/ws/v1/events.ts index 57c449943..ebf4effbe 100644 --- a/packages/agent-gateway/src/transport/ws/v1/events.ts +++ b/packages/agent-gateway/src/transport/ws/v1/events.ts @@ -97,30 +97,15 @@ export interface ConfigWarningItem { readonly message: string; } -/** - * Global config warnings (deprecated keys / env vars in use, invalid - * sections). Pushed live to every connection whenever the config service's - * warning set changes; an empty `warnings` array means the last warning - * cleared. Late joiners are not replayed — pull current warnings via the - * config diagnostics RPC surface instead. - */ export interface ConfigWarningEvent { readonly type: 'event.config.warning'; readonly warnings: readonly ConfigWarningItem[]; } -/** - * Plugin set mutation (install / enable / disable / remove from any client). - * Bare fan-out signal — clients re-read the plugins REST surface. - */ export interface PluginChangedEvent { readonly type: 'event.plugin.changed'; } -/** - * Capability install progress transition. Global fan-out; clients update the - * row live and re-read the capability once it settles (`running: false`). - */ export interface CapabilityChangedEvent { readonly type: 'event.capability.changed'; readonly capability_id: string; @@ -133,14 +118,8 @@ export interface CapabilityChangedEvent { }; } -/** - * DI unit state transition of the engine's scope tree, produced by - * agent-core-v2's `IDebugCascadeService` (the L5 debug surface feed). Global: - * carries no owning session and fans out to every connection. - */ export interface DiUnitChangedEvent { readonly type: 'event.di.unit_changed'; - /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ readonly scope: string; readonly token: string; readonly state: 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; @@ -200,12 +179,6 @@ export type TaskInfo = | AgentTaskInfo | QuestionTaskInfo; -/** - * Legacy background-task lifecycle events (`background.task.started` / - * `background.task.terminated`). The v2 engine emits `task.started` / - * `task.terminated`; the broadcaster re-spells them onto these legacy names so - * older clients see a consistent stream. - */ export interface BackgroundTaskStartedEvent { readonly type: 'background.task.started'; readonly info: TaskInfo; @@ -259,10 +232,6 @@ export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; const volatileEventTypeSet: ReadonlySet = new Set(VOLATILE_EVENT_TYPES); -/** - * Volatile-vs-durable classification for the global / model event paths (the - * agent path uses the local `isVolatileSignal` in the broadcaster instead). - */ export function isVolatileEventType(type: string): type is VolatileEventType { return volatileEventTypeSet.has(type); } diff --git a/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts b/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts index 5972d6880..e9f7ef79e 100644 --- a/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts @@ -38,7 +38,6 @@ export interface FsChangedFrame { readonly payload: FsChangeEvent; } -/** Minimal connection surface the bridge needs (satisfied by `WsConnectionV1`). */ export interface FsWatchConnection { readonly id: string; send(envelope: EventEnvelope): void; @@ -164,7 +163,6 @@ export class FsWatchBridge { return this.ok(sw, conn); } - /** Drop every subscription held by `conn` (called on socket close). */ detachConnection(conn: FsWatchConnection): void { for (const sw of Array.from(this.bySession.values())) { const entry = sw.conns.get(conn.id); @@ -298,7 +296,6 @@ export class FsWatchBridge { } } - /** Lexical confinement + workspace-relative normalization (no `stat`). */ private normalize(sw: SessionWatch, raw: string): string | undefined { if (raw === '' || raw === '/') return undefined; if (isAbsolute(raw)) return undefined; diff --git a/packages/agent-gateway/src/transport/ws/v1/inFlightTurnTracker.ts b/packages/agent-gateway/src/transport/ws/v1/inFlightTurnTracker.ts index f1c061f3e..5f268ce9f 100644 --- a/packages/agent-gateway/src/transport/ws/v1/inFlightTurnTracker.ts +++ b/packages/agent-gateway/src/transport/ws/v1/inFlightTurnTracker.ts @@ -24,7 +24,6 @@ interface TurnAccum { } export interface VolatileAnnotation { - /** Pre-append offset for text-delta frames. */ offset?: number; } diff --git a/packages/agent-gateway/src/transport/ws/v1/protocol.ts b/packages/agent-gateway/src/transport/ws/v1/protocol.ts index 01a14d526..3af8dd6fb 100644 --- a/packages/agent-gateway/src/transport/ws/v1/protocol.ts +++ b/packages/agent-gateway/src/transport/ws/v1/protocol.ts @@ -1,7 +1,6 @@ export interface ServerHelloPayload { ws_connection_id: string; protocol_version: number; - /** Server heartbeat cadence — a `ping` frame arrives at least this often. */ heartbeat_ms: number; max_event_buffer_size: number; capabilities: { diff --git a/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts b/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts index 6eecd93f6..406967e8d 100644 --- a/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts +++ b/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts @@ -12,7 +12,6 @@ import { selectWsBearerProtocol } from '../bearerProtocol'; export const WS_PATH = '/api/v1/ws'; export interface RegisterWsV1Options { - /** Present-only credential validator forwarded to {@link WsConnectionV1}. */ readonly validateCredential?: CredentialValidator; readonly registry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; @@ -22,7 +21,6 @@ export interface RegisterWsV1Options { readonly flushIntervalMs?: number; readonly maxBatchSize?: number; readonly highWaterMarkBytes?: number; - /** Heartbeat ping cadence override — tests inject small values. */ readonly heartbeatIntervalMs?: number; } diff --git a/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts index 43a94ef98..7875cf23b 100644 --- a/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -66,7 +66,6 @@ export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_chan export interface BufferedSinceResult { events: Array<{ seq: number; envelope: EventEnvelope }>; - /** When set, the client must rebuild from the snapshot and re-subscribe. */ resyncRequired: ResyncReason | false; currentSeq: number; epoch: string; @@ -79,30 +78,14 @@ export interface SessionSnapshotState { subagents: SnapshotSubagent[]; } -/** Internal transport lane: only subscription traffic enters the timed buffer. */ export type BroadcastDelivery = 'subscription' | 'immediate'; -/** A connection (or test double) that receives sequenced envelopes. */ export interface BroadcastTarget { send(envelope: EventEnvelope, delivery?: BroadcastDelivery): void; } -/** - * Per-subscription agent allowlist for fine-grained v1 event delivery. - * `undefined` (or omitted) means "receive every agent" — the legacy - * session-grained behavior. A `ReadonlySet` restricts delivery to the listed - * agent ids; global events ({@link isGlobalEvent}) bypass the filter entirely. - */ export type AgentFilter = ReadonlySet | undefined; -/** - * What one connection wants from a session: two independent dimensions. The - * legacy agent allowlist gates `session_event` delivery only; the opt-in - * per-agent transcript grades (`Record`; absent = all - * 'off' — legacy clients see no transcript frames at all) alone decide which - * agents' transcript frames the connection receives — the allowlist does NOT - * gate the transcript stream. - */ export interface TargetSubscription { readonly agentFilter?: AgentFilter; readonly transcriptGrades?: TranscriptGradeSpec; @@ -145,35 +128,8 @@ async function disposeSessionState(state: SessionState): Promise { export class SessionEventBroadcaster { private readonly sessions = new Map(); - /** - * Every established connection, subscribed or not. Global events - * ({@link isGlobalEvent}) fan out to this set (union the per-session - * targets) so a freshly connected client sees session-level facts — - * `event.session.created`, `session.meta.updated`, and every activated - * session's `event.session.work_changed` — without subscribing to anything. - */ private readonly globalTargets = new Set(); - /** - * Opt-in set for the `event.di.*` debug-surface feed. That feed is global - * (no owning session) and high-churn, but only pythinker-inspect's DI view - * consumes it — pushing it to every connection wastes bandwidth on clients - * that drop the frames unread. Temporary gate until a client-declared - * event-type whitelist exists: `WsConnectionV1` opts a connection in when - * its `client_hello` carries `client_id: 'pythinker-inspect'`; every other - * connection (including subscribed targets) skips `event.di.*` frames. - */ private readonly diEventTargets = new Set(); - /** - * Single-flight guard for session activation: without it, two concurrent - * activations (WS subscribe racing a REST snapshot / replay / resync) each - * built their own SessionState, bus subscriptions, and journal writer. The - * leaked listeners all route through `onAgentEvent`, which looks up the - * current state by session id, so they advance the SAME tracker and journal: - * one source delta is emitted at consecutive offsets and adjacent durable - * events receive distinct consecutive seqs. WS coalescing then folds the - * adjacent delta copies into one doubled payload, producing the observed - * per-chunk `AABBCC` stream while every seq and offset still looks valid. - */ private readonly pendingStates = new Map>(); private readonly maxBufferSize: number; private readonly coreEventSubscription: IDisposable; @@ -194,42 +150,19 @@ export class SessionEventBroadcaster { .subscribe((event) => this.onCoreEvent(event)); } - /** - * Register a freshly established connection for global-event fan-out. The - * connection receives every global event ({@link isGlobalEvent}) from this - * point on, with no per-session subscription required. Idempotent. - */ addGlobalTarget(target: BroadcastTarget): void { this.globalTargets.add(target); } - /** Drop a closed connection from the global fan-out set. Idempotent. */ removeGlobalTarget(target: BroadcastTarget): void { this.globalTargets.delete(target); this.diEventTargets.delete(target); } - /** - * Opt a connection into the `event.di.*` debug-surface feed (see - * {@link diEventTargets}). Idempotent; cleaned up by - * {@link removeGlobalTarget}. - */ addDiEventTarget(target: BroadcastTarget): void { this.diEventTargets.add(target); } - /** - * Subscribe a connection to a session's stream (activates the session). - * - * When `transcriptGrades` is present the connection also joins the - * session's transcript stream: every already-known agent whose grade is not - * 'off' and that is an upgrade over the connection's previous grade - * (`needsResetOnTransition`) is seeded with a `transcript.reset` snapshot; - * later ops arrive as `transcript.ops`. Transcript frames are ALWAYS - * volatile (current watermark as seq, never journaled, never replayed) — - * frame loss surfaces through the ordinary backpressure → `resync_required` - * → REST + re-subscribe path, which resets the transcript naturally. - */ async subscribe( sessionId: string, target: BroadcastTarget, @@ -265,10 +198,6 @@ export class SessionEventBroadcaster { return true; } - /** - * Whether `subscribeTranscript` will send at least one reset for this - * (target, spec) pair right now — an upgrade over the previous grades. - */ private willSendTranscriptReset( state: SessionState, spec: TranscriptGradeSpec, @@ -288,16 +217,6 @@ export class SessionEventBroadcaster { return false; } - /** - * Send the transcript baseline deferred by `subscribe(deferTranscriptReset)` - * — callers run it after their cursor replay so the reset never lands ahead - * of the replayed (lower-seq) backlog. The baseline is forced for every - * admitted agent (no previous grades): volatile ops fanned out while the - * target sat unseeded were dropped, so only a full reset closes that gap — - * unless the subscription carried a `transcriptSince` cursor the journal - * still covers, in which case replaying exactly the missed batches closes - * it and no reset is sent for that agent. - */ async flushTranscriptSeed(sessionId: string, target: BroadcastTarget): Promise { const state = this.sessions.get(sessionId); if (state === undefined) return; @@ -316,17 +235,6 @@ export class SessionEventBroadcaster { state.deferredTranscriptSeeds.delete(target); } - /** - * Detach one connection's transcript grade stream — agent-grained. With - * `agentIds`, only the listed agents drop to an explicit 'off' (a listed - * '*' removes the wildcard default); without it, the whole stream goes. - * Non-activating and idempotent: unknown sessions/targets are no-ops. A - * detached agent stops streaming on the next ops batch and its legacy - * session_events resume automatically (both paths re-read the per-agent - * grade); when no non-'off' grade remains the spec collapses to - * `undefined`, the seeded/deferred baselines are dropped, and any in-flight - * `subscribeTranscript` aborts on its grade re-read. - */ unsubscribeTranscript( sessionId: string, target: BroadcastTarget, @@ -347,21 +255,6 @@ export class SessionEventBroadcaster { } } - /** - * Handle one connection's transcript subscription: attach the shared - * per-session stream on first use and send `transcript.reset` snapshots for - * every known agent admitted by `spec` that is an upgrade over the - * connection's previous grade. A cold session (not live in this process) - * silently skips streaming — cold transcripts stay REST-only. Live sessions - * first await the initial wire-records backfill, so the seeded resets carry - * the established main-agent transcript. Explicitly graded agents AND roster - * agents admitted via the wildcard get their persisted history replayed - * before their first reset — a roster agent whose `AgentTranscript` was - * never materialized has nothing to snapshot, so without the backfill its - * baseline is silently skipped. Grades are re-read from `state.targets` - * after the awaits: subscribe work runs asynchronously, and a newer - * subscribe/unsubscribe must not be answered with stale resets. - */ private async subscribeTranscript( state: SessionState, target: BroadcastTarget, @@ -407,11 +300,6 @@ export class SessionEventBroadcaster { } } - /** - * Replay journaled op batches to one connection (the `transcript_since` - * catch-up path), grade-filtered like the live fan-out and stamped with - * their original batch seqs. - */ private replayTranscriptOps( state: SessionState, target: BroadcastTarget, @@ -435,17 +323,6 @@ export class SessionEventBroadcaster { } } - /** - * Attach the session's shared transcript fan-out: one mapped-ops - * subscription for the whole session (grade filtering happens per target at - * fan-out). New agents appearing later seed a `transcript.reset` for every - * connected target whose grade admits them. The attachment is pinned to the - * store instance: when the engine session closes, the service drops the - * store together with its ops listener set while this session state - * survives, so a subscribe after an in-daemon session resume must - * re-register the fan-out against the rebuilt store — returning early on - * any stale stream would deliver resets but never the live ops. - */ private ensureTranscriptStream(state: SessionState, store: TranscriptStore): void { if (state.transcriptStream?.store === store) return; const service = this.opts.transcriptService; @@ -497,11 +374,6 @@ export class SessionEventBroadcaster { ); } - /** - * Volatile `transcript.reset` baseline: an items-empty snapshot (global - * state only, redacted to the target's grade) plus the seq watermark. - * History is paged over REST; live ops stream from the watermark. - */ private sendTranscriptReset( state: SessionState, target: BroadcastTarget, @@ -522,12 +394,6 @@ export class SessionEventBroadcaster { ); } - /** - * All transcript frames are volatile and carry the current durable watermark - * as `seq` (they never advance it and are never journaled or replayed). The - * payload is the flat protocol event (`{ type, agent_id, … }`), matching the - * `transcriptResetEventSchema` / `transcriptOpsEventSchema` shapes. - */ private buildTranscriptEnvelope( state: SessionState, type: 'transcript.reset' | 'transcript.ops', @@ -602,7 +468,6 @@ export class SessionEventBroadcaster { return { seq: state.journal.seq, epoch: state.journal.epoch }; } - /** Atomic-at-queue watermark + in-flight turn, for the snapshot route. */ async getSnapshotState(sessionId: string): Promise { const state = await this.ensureState(sessionId); if (state === undefined) { @@ -620,13 +485,6 @@ export class SessionEventBroadcaster { }; } - /** - * Watermark for a session that is not live in this process but exists on disk - * (carried over from a prior process, or created by v1). Opens the journal - * transiently — no agent/interaction listeners and not cached in - * `this.sessions` — so a later live activation still attaches subscriptions. - * Returns `undefined` when the session is unknown to the index (truly absent). - */ private async readColdWatermark( sessionId: string, ): Promise<{ seq: number; epoch: string } | undefined> { @@ -881,12 +739,6 @@ export class SessionEventBroadcaster { .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); } - /** - * Dispatch an event through a real session's state so the WS envelope carries - * the real `session_id` (not the global `'__global__'` watermark). Used for - * session-scoped core events that must still fan out to every connection - * (e.g. `session.meta.updated`); `isGlobalEvent` keeps the fan-out global. - */ private async dispatchSessionEvent(sessionId: string, event: Event): Promise { let state: SessionState | undefined; try { @@ -903,19 +755,6 @@ export class SessionEventBroadcaster { .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); } - /** - * Bridge the core's session work aggregate (`ISessionActivityView`) onto - * the v1 `event.session.work_changed` frame. The view owns the fold and - * its change dedup; the edge only schedules the wire emission. Every cause - * except `turn_ended` emits immediately. A `turn_ended` change must land - * after the matching `turn.ended` frame, but the agent bus fires - * full-stream subscribers (the edge's own handler) before per-type ones - * (the activity view chain that reports this change) — so the state is - * buffered and flushed from a microtask: the microtask runs after the - * whole synchronous publish, by which time the `turn.ended` frame is - * already enqueued on the session queue, and the emission can never be - * stranded behind a flush that already ran. - */ private attachWorkView(session: ISessionScopeHandle, state: SessionState): void { const workView = session.accessor.get(ISessionActivityView); workView.state(); @@ -1069,12 +908,6 @@ export class SessionEventBroadcaster { } } - /** - * Bridge the session's interaction kernel (approvals / questions) onto the - * v1 event stream. The kernel only emits in-process notifications - * (`onDidChangePending` / `onDidResolve`), so the v1 protocol events are - * synthesized here. - */ private attachInteractions( sessionId: string, session: ISessionScopeHandle, @@ -1116,11 +949,6 @@ export class SessionEventBroadcaster { .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); } - /** - * Emit `event.session.work_changed` for one aggregate change announced by - * the core `ISessionActivityView` (the view already dedups — every call - * here is a real tuple change). - */ private enqueueWorkChanged(state: SessionState, work: SessionActivityState): void { state.queue = state.queue .then(() => @@ -1143,10 +971,6 @@ export class SessionEventBroadcaster { ); } - /** - * Log a rejected `dispatchSessionEvent` promise — the session's scope was - * torn down mid-dispatch, or a non-disposed error escaped `ensureState`. - */ private logDispatchError(sessionId: string, eventType: string, error: unknown): void { const logger = this.opts.logger; if (logger === undefined) return; @@ -1157,10 +981,6 @@ export class SessionEventBroadcaster { } } - /** - * A queued dispatch rejected: the event is permanently lost (and, for durable - * events, the seq is skipped). Warn instead of swallowing it silently. - */ private logDispatchDropped(sessionId: string, eventType: string, error: unknown): void { this.opts.logger?.warn( { sessionId, eventType, err: error }, diff --git a/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts b/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts index eb0a23b96..34d2ef323 100644 --- a/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts @@ -7,11 +7,6 @@ import { resolveStoragePath } from '../../../lib/storagePath'; const JOURNAL_VERSION = 1; -/** - * Wire event envelope — matches `wsEventEnvelopeSchema` / - * `sessionEventMessageSchema` in the local `protocol/ws-control` catalog. Defined - * structurally so the journal does not depend on the zod schema at runtime. - */ export interface EventEnvelope { readonly type: string; readonly seq: number; @@ -41,7 +36,6 @@ export interface JournalEntry { envelope: EventEnvelope; } -/** Minimal logger surface — keeps the journal decoupled from the server logger. */ export interface JournalLogger { warn(obj: unknown, msg: string): void; error?(obj: unknown, msg: string): void; @@ -66,16 +60,10 @@ export class SessionEventJournal { this.headerPending = isFresh; } - /** Highest durable seq appended (0 if none). */ get seq(): number { return this._seq; } - /** - * Open (or create) the journal for `sessionId`. Scans an existing file to - * recover `{epoch, lastSeq}`. A missing file or an unreadable header starts - * a fresh journal with a new epoch. - */ static async open( eventsDir: string, sessionId: string, @@ -116,20 +104,17 @@ export class SessionEventJournal { return new SessionEventJournal(filePath, logger, epoch, lastSeq, false); } - /** Reserve the next durable seq. The caller must follow with `append()`. */ nextSeq(): number { this._seq += 1; return this._seq; } - /** Queue a durable event line for write-behind flush. */ append(seq: number, envelope: EventEnvelope): void { const line: JournalEventLine = { kind: 'event', seq, envelope }; this.pendingLines.push(JSON.stringify(line)); this.scheduleFlush(); } - /** Read journal entries with `seq > fromSeqExclusive`, capped at `limit`. */ async readSince(fromSeqExclusive: number, limit: number): Promise { await this.flush(); const out: JournalEntry[] = []; diff --git a/packages/agent-gateway/src/transport/ws/v1/subagentRosterTracker.ts b/packages/agent-gateway/src/transport/ws/v1/subagentRosterTracker.ts index 5e82eb242..a4f104407 100644 --- a/packages/agent-gateway/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/agent-gateway/src/transport/ws/v1/subagentRosterTracker.ts @@ -96,7 +96,6 @@ export class SubagentRosterTracker { } } - /** Fresh copies — callers must not mutate the tracked entries. */ get(sessionId: string): SnapshotSubagent[] { const roster = this.bySession.get(sessionId); if (!roster) return []; diff --git a/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts b/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts index 28167808e..88ebf554f 100644 --- a/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts @@ -57,25 +57,14 @@ export interface WsConnectionV1Options { readonly broadcaster: SessionEventBroadcaster; readonly fsWatchBridge?: FsWatchBridge; readonly connectionRegistry: IConnectionRegistry; - /** - * Present-only credential check for the post-connect `client_hello` - * handshake. The WebSocket upgrade handler (`start.ts`) is the real auth - * gate; this is defense-in-depth so a presented handshake token must still - * be valid. A missing token is accepted (the production web client sends - * the bearer at the upgrade and no token in `client_hello`). - */ readonly validateCredential?: CredentialValidator; readonly remoteAddress: string | null; readonly userAgent: string | null; readonly logger?: JournalLogger; readonly maxBufferSize?: number; - /** Delay before buffered subscription events are flushed. */ readonly flushIntervalMs?: number; - /** Flush subscription events once this many frames are queued. */ readonly maxBatchSize?: number; - /** `socket.bufferedAmount` above which flushing is deferred (backpressure). */ readonly highWaterMarkBytes?: number; - /** Heartbeat ping cadence; advertised as `heartbeat_ms` in `server_hello`. */ readonly heartbeatIntervalMs?: number; } @@ -98,26 +87,15 @@ export class WsConnectionV1 implements BroadcastTarget { private closed = false; private gotClientHello = false; - /** Per-session subscription state: legacy agent allowlist + opt-in transcript grades. */ readonly subscriptions = new Map(); - /** - * Serializes control-frame handling in receive order. Frames arrive - * back-to-back (e.g. `client_hello` immediately followed by - * `subscribe_v2`), and a later handler reads subscription state the - * earlier one stores — without the queue, two async attaches could - * interleave and the stale one would overwrite the fresher state. - */ private controlQueue: Promise = Promise.resolve(); - /** Outbound frames awaiting the next flush. */ private outbound: unknown[] = []; private flushTimer?: ReturnType; private backpressureRetryTimer?: ReturnType; - /** Epoch ms when the current backpressure deferral started; caps the wait. */ private backpressureSince?: number; private heartbeatTimer?: ReturnType; - /** Epoch ms of the most recent inbound frame — any frame proves the peer is alive. */ private lastInboundAt = Date.now(); constructor(opts: WsConnectionV1Options) { @@ -165,7 +143,6 @@ export class WsConnectionV1 implements BroadcastTarget { return Array.from(this.subscriptions.keys()).sort(); } - /** BroadcastTarget — buffer subscription traffic; public traffic is a FIFO barrier. */ send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { if (delivery === 'immediate') this.sendImmediateFrame(envelope); else this.sendSubscribedFrame(envelope); @@ -216,11 +193,6 @@ export class WsConnectionV1 implements BroadcastTarget { }); } - /** - * Heartbeat tick: reap first, ping second. A peer silent for two full cycles - * (no pong, no control traffic at all) is half-open — close it rather than - * ping a dead pipe. The close also fires the client's reconnect path. - */ private onHeartbeat(): void { if (Date.now() - this.lastInboundAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) { this.close(1001, 'heartbeat timeout'); @@ -296,14 +268,6 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - /** - * `subscribe_v2` — the ONLY transcript subscription channel: attach or - * update this connection's per-agent transcript grades for ONE session. - * Carries no durable cursor (transcript frames are volatile), so the - * baseline/catch-up decision lives entirely in the broadcaster's - * `subscribeTranscript` (`transcript_since` journal replay vs reset). A - * legacy agent allowlist already held for the session is preserved. - */ private async onSubscribeV2(frame: InboundFrame): Promise { const parsed = transcriptSubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); if (!parsed.success) { @@ -336,14 +300,6 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - /** - * `unsubscribe_v2` — the agent-grained counterpart of `subscribe_v2`: - * detach the listed agents' transcript streams (`agent_ids` absent = the - * whole session's stream) while leaving the legacy event subscription and - * its agent allowlist untouched. Idempotent and never activates a session; - * a detached agent's legacy `session_event`s resume in full as the - * suppression lifts with its grade. - */ private async onUnsubscribeV2(frame: InboundFrame): Promise { const parsed = unsubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); if (!parsed.success) { @@ -422,16 +378,6 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - /** - * Shared attach path behind `client_hello` (legacy inline subscriptions) - * and `subscribe`. Subscribes the connection via the broadcaster, then - * either replays durable events since the client's cursor (with the - * transcript baseline deferred until after the replay — its seq must - * follow the replayed backlog, never precede it) or reports the server's - * current cursor. Unknown sessions land in `collectors.notFound` when the - * caller is `subscribe`, otherwise in `resyncRequired` (the hello ack has - * no `not_found` field). - */ private async attachSession( sid: string, cursor: SessionCursor | undefined, @@ -504,7 +450,6 @@ export class WsConnectionV1 implements BroadcastTarget { return true; } - /** Queue an event delivered through `subscribe` / `subscribe_v2`. */ private sendSubscribedFrame(msg: unknown): void { if (this.closed) return; this.outbound.push(msg); @@ -515,10 +460,6 @@ export class WsConnectionV1 implements BroadcastTarget { this.scheduleFlush(); } - /** - * Public/control frames do not start a timer. They join the FIFO and flush it - * immediately, so no later frame can overtake earlier subscription traffic. - */ private sendImmediateFrame(msg: unknown): void { if (this.closed) return; this.outbound.push(msg); @@ -534,13 +475,6 @@ export class WsConnectionV1 implements BroadcastTarget { this.flushTimer.unref?.(); } - /** - * Drain the outbound buffer: coalesce adjacent compatible volatile deltas, - * then write the surviving frames to the socket. When the peer is not - * draining (`bufferedAmount` above the high-water mark) and `force` is not - * set, defer and keep accumulating — later deltas merge into the queued - * ones, so the frame count does not grow while we wait. - */ private flush(force = false): void { if (this.flushTimer !== undefined) { clearTimeout(this.flushTimer); @@ -656,22 +590,6 @@ function isCoalescableDelta(frame: unknown): frame is CoalescableDelta { return typeof (payload as Record)['delta'] === 'string'; } -/** - * Merge adjacent compatible volatile text deltas into a single envelope. - * - * Two adjacent frames merge when both are `volatile` `assistant.delta` / - * `thinking.delta` of the same type, addressed to the same session, agent, - * and turn. The merged frame keeps the first frame's `seq` / `offset` / - * `timestamp` and concatenates `payload.delta` in order — the client's - * offset-based alignment against the in-flight snapshot stays correct - * (the broadcaster's per-session dispatch queue guarantees consecutive deltas - * for a turn carry consecutive offsets). - * - * Durable events, control frames, and non-text deltas are never merged, and - * merging never crosses a non-mergeable frame, so overall ordering is - * preserved. The input frames are not mutated; merged results are fresh - * objects. Exported for unit testing. - */ export function coalesceFrames(frames: readonly unknown[]): unknown[] { const out: unknown[] = []; for (const frame of frames) { diff --git a/packages/agent-gateway/test/helpers/fixedAuth.ts b/packages/agent-gateway/test/helpers/fixedAuth.ts index 573044de1..4c44055ab 100644 --- a/packages/agent-gateway/test/helpers/fixedAuth.ts +++ b/packages/agent-gateway/test/helpers/fixedAuth.ts @@ -1,10 +1,5 @@ import type { IAuthTokenService } from '../../src/services/auth/authTokenService'; -/** - * Deterministic `IAuthTokenService` for tests that need a known token without - * touching the on-disk `server.token` store. Injected via - * `startServer({ authTokenService: fixedTokenAuth(...) })`. - */ export function fixedTokenAuth(token = 'test-token'): IAuthTokenService { return { _serviceBrand: undefined, diff --git a/packages/agent-gateway/test/helpers/hostIdentity.ts b/packages/agent-gateway/test/helpers/hostIdentity.ts index 871bb85cc..2bd494823 100644 --- a/packages/agent-gateway/test/helpers/hostIdentity.ts +++ b/packages/agent-gateway/test/helpers/hostIdentity.ts @@ -1,10 +1,5 @@ import type { ServerHostIdentity } from '../../src/start'; -/** - * Neutral fixture identity for agent-gateway tests — stands in for the embedding - * host's product identity, which `startServer` requires. Tests that care - * about a specific version or platform build their own literal instead. - */ export const TEST_HOST_IDENTITY: ServerHostIdentity = { productName: 'test-host', version: '0.0.0-test', diff --git a/packages/transcript/src/contract/events.ts b/packages/transcript/src/contract/events.ts index fc0dfb398..0d417d7a9 100644 --- a/packages/transcript/src/contract/events.ts +++ b/packages/transcript/src/contract/events.ts @@ -16,17 +16,11 @@ export const transcriptEventSchema = z.discriminatedUnion('type', [ transcriptOpsEventSchema, ]); -/** - * The TS event shapes live on the domain model (readonly), NOT on zod output - * (mutable, purely structural) — the schemas above validate WS payloads, the - * types below are what server and client code actually exchange. - */ export interface TranscriptResetEvent { readonly type: 'transcript.reset'; readonly agent_id: string; readonly snapshot: AgentTranscriptSnapshot; readonly has_more_older: boolean; - /** Watermark: the snapshot includes every op batch with seq <= N. */ readonly seq?: number; } @@ -34,7 +28,6 @@ export interface TranscriptOpsEvent { readonly type: 'transcript.ops'; readonly agent_id: string; readonly ops: readonly TranscriptOperation[]; - /** This batch's sequence number (consecutive per agent). */ readonly seq?: number; } diff --git a/packages/transcript/src/contract/mediaRef.ts b/packages/transcript/src/contract/mediaRef.ts index 57fcf17ed..678820ef7 100644 --- a/packages/transcript/src/contract/mediaRef.ts +++ b/packages/transcript/src/contract/mediaRef.ts @@ -8,13 +8,6 @@ export interface MediaPathTagMatch { const SINGLE_MEDIA_PATH_TAG_RE = /^\s*<(image|video|audio|file)\b[^>]*?\bpath="([^"]*)"[^>]*>(?:<\/\1>)?\s*$/; -/** - * The whole text is exactly one media path tag (surrounding whitespace - * tolerated) — the mirror of the engine's `matchSingleMediaPathTag`. - * Tolerates extra attributes and a missing closing tag, like the engine - * grammar. Tags embedded in larger user text are NOT matched: stripping - * there would eat user content. - */ export function matchMediaPathTagText(text: string): MediaPathTagMatch | undefined { const match = SINGLE_MEDIA_PATH_TAG_RE.exec(text); if (match === null) return undefined; @@ -31,16 +24,10 @@ function unescapeMediaAttribute(value: string): string { const PYTHINKER_FILE_SCHEME = 'pythinker-file://'; -/** The daemon upload reference behind a `pythinker-file://` url. */ export interface DaemonFileRef { readonly fileId: string; } -/** - * Parse a `pythinker-file://` url — the mirror of the engine's - * `parseDaemonFileUrl`. A legacy `?path=` query (the retired persisted - * materialization path) is stripped and ignored. - */ export function parseDaemonFileRef(url: string): DaemonFileRef | undefined { if (!url.startsWith(PYTHINKER_FILE_SCHEME)) return undefined; const rest = url.slice(PYTHINKER_FILE_SCHEME.length); @@ -49,16 +36,10 @@ export function parseDaemonFileRef(url: string): DaemonFileRef | undefined { return fileId.length > 0 ? { fileId } : undefined; } -/** The daemon upload id behind a `pythinker-file://` url. */ export function parseDaemonFileRefFileId(url: string): string | undefined { return parseDaemonFileRef(url)?.fileId; } -/** - * The structural minimum the daemon-ref extraction needs from a content - * part — the kosong `text` / `image_url` / `video_url` shapes plus anything - * else. - */ export interface MediaRefPart { readonly type: string; readonly text?: string; @@ -66,12 +47,6 @@ export interface MediaRefPart { readonly videoUrl?: { readonly url?: string }; } -/** - * The daemon reference behind a content part, if any — the mirror of the - * engine's `daemonFileRefFromPart` (keep the two in sync): the kind comes - * from the part type, the file id from the `pythinker-file://` url. This is the - * single part → ref extraction read models share. - */ export function daemonFileRefFromPairingPart( part: MediaRefPart, ): { readonly kind: 'image' | 'video'; readonly ref: DaemonFileRef } | undefined { diff --git a/packages/transcript/src/contract/schema.ts b/packages/transcript/src/contract/schema.ts index 021f8ed5b..4148d9a17 100644 --- a/packages/transcript/src/contract/schema.ts +++ b/packages/transcript/src/contract/schema.ts @@ -8,12 +8,6 @@ export const agentIdSchema = z.string().min(1); const AGENT_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; -/** - * Whether an agent id is a single plain name. Ids are joined into filesystem - * paths server-side (`/agents//`), so anything - * path-hostile must be rejected before it can escape the agents directory - * or crash the read. - */ export function isPlainAgentId(agentId: string): boolean { return AGENT_ID_PATTERN.test(agentId) && agentId !== '.' && agentId !== '..'; } @@ -39,7 +33,6 @@ export const transcriptUsageSchema = z.object({ cost: z.number().optional(), }); -/** Step token usage — the engine's `TokenUsage` wire shape, verbatim. */ export const stepUsageSchema = z.object({ inputOther: z.number(), output: z.number(), @@ -227,7 +220,6 @@ export const modesMetaSchema = z.object({ tower: z.object({}).optional(), }); -/** `meta.merge` contract shape: a mode key set to `null` clears that badge. */ export const modesMetaMergeSchema = z.object({ plan: z .object({ reviewPath: z.string().optional(), version: z.number().optional() }) @@ -237,7 +229,6 @@ export const modesMetaMergeSchema = z.object({ tower: z.object({}).nullable().optional(), }); -/** Same shape as the wire `agentPhaseSchema`, re-declared (this package must not import the server). */ export const agentPhaseMetaSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('idle') }), z.object({ @@ -326,7 +317,6 @@ export const transcriptMetaSchema = z.object({ agent: agentStatusMetaSchema.optional(), }); -/** `goal` set to `null` in a merge clears the goal (same convention as mode keys). */ export const transcriptMetaMergeSchema = transcriptMetaSchema.extend({ goal: goalMetaSchema.nullable().optional(), modes: modesMetaMergeSchema.optional(), @@ -434,43 +424,10 @@ export const transcriptOpBatchSchema = z.object({ export const transcriptGradeSchema = z.enum(['off', 'turn', 'block', 'delta']); -/** - * Transcript op-batch sequence number. Semantics (the protocol contract all - * peers implement against): - * - * - Scope: per (session, agent). Starts at 1; the server increments it once - * per DISPATCHED OP BATCH (not per op), so batch seqs are consecutive. - * - Watermark: a `seq` on `transcript.reset` or on the REST transcript - * response means "this state includes every batch with seq <= N". - * - Catch-up: a client holding watermark N asks for batches with seq > N - * (`GET .../transcript/ops?since_seq=N`, or the `transcript_since` - * subscription cursor). A `complete: false` catch-up response means the - * server's journal no longer reaches back to N — the client MUST fall - * back to a full REST refresh. - * - Legacy: seq is optional on every shape. A peer that omits it speaks the - * pre-seq protocol; consumers fall back to loss-signal-driven refreshes. - */ export const transcriptSeqSchema = z.number().int().nonnegative(); -/** - * Per-session grade map: `'*'` is the default, explicit agent ids override. - * Record. - */ export const transcriptGradeSpecSchema = z.record(z.string(), transcriptGradeSchema); -/** - * Wire payload of the v1 WS `subscribe_v2` control frame — the ONLY carrier of - * transcript subscriptions: one session, its grade map, and the optional - * per-agent op-batch seq cursor. This contract is owned by THIS package - * (transcript types never live in `@pymodel/protocol`); the v1 connection - * layer validates the payload with this schema and answers malformed frames - * with an ack error. - * - * `transcript_since`: `Record` — the caller's last applied - * op-batch seq per agent. When present and the server's journal still covers - * it, the server replays the missing batches instead of sending a baseline - * `transcript.reset`; otherwise it falls back to the reset. - */ export const transcriptSubscribeV2PayloadSchema = z.object({ session_id: z.string().min(1), transcript: transcriptGradeSpecSchema, @@ -479,14 +436,6 @@ export const transcriptSubscribeV2PayloadSchema = z.object({ export type TranscriptSubscribeV2Payload = z.infer; -/** - * `GET /v1/sessions/{session_id}/transcript` contract shape, owned by this - * package: `agent_id` (required) + turn cursor (`before_turn` / `after_turn`, - * mutually exclusive) + `page_size` (default 20, max 100). The page unit is - * the turn (contiguous turn slice plus segment markers/taskrefs); `tasks`, - * `interactions`, `meta`, `agents` and `pending_interactions` are global - * state and ship unpaginated with every response. - */ export const transcriptQuerySchema = z .object({ agent_id: agentIdSchema, @@ -535,12 +484,6 @@ export const transcriptResponseSchema = z.object({ seq: transcriptSeqSchema.optional(), }); -/** - * `GET /v1/sessions/{session_id}/transcript/ops` response: journaled op - * batches with seq > `since_seq`, oldest first. `complete: false` means the - * journal does not reach back to `since_seq` (or the session is not live) — - * the caller must fall back to a full transcript refresh. - */ export const transcriptOpsCatchupResponseSchema = z.object({ agent_id: agentIdSchema, batches: z.array( @@ -550,12 +493,6 @@ export const transcriptOpsCatchupResponseSchema = z.object({ complete: z.boolean(), }); -/** - * One turn-opening input, projected out of a transcript for the - * user-messages read: every turn whose `prompt` is defined (real user text, - * user-slash skill/plugin commands, cron prompts, …). `origin` stays on the - * entry so the caller can tell those kinds apart. - */ export const transcriptUserMessageSchema = z.object({ turn_id: turnIdSchema, ordinal: z.number().int(), @@ -566,13 +503,6 @@ export const transcriptUserMessageSchema = z.object({ started_at: z.string().optional(), }); -/** - * `GET /v1/sessions/{session_id}/transcript/user-messages` contract shape: - * per-agent user messages (agents are separate transcripts — user input is - * each agent's own). `agent_id` optional on the query: present reads one - * agent, absent reads every rostered agent. `attachments` carries the - * entities referenced by the listed messages (metadata only, never bytes). - */ export const transcriptUserMessagesResponseSchema = z.object({ agents: z.array( z.object({ @@ -583,23 +513,12 @@ export const transcriptUserMessagesResponseSchema = z.object({ ), }); -/** - * The review round-trip of one ExitPlanMode call, projected from the linked - * approval interaction. Absent when the call never went through an - * interactive review (auto permission mode, or a configured allow rule). - */ export const transcriptPlanReviewSchema = z.object({ state: z.enum(['pending', 'approved', 'rejected', 'cancelled']), selected_option: z.string().optional(), feedback: z.string().optional(), }); -/** - * One ExitPlanMode call's plan information. `source` records which fact the - * content was projected from — the linked approval interaction's `request` - * display (interactive review), the live tool frame's display (auto mode), - * or the tool result output text (cold rebuilds without an interaction). - */ export const transcriptPlanEntrySchema = z.object({ tool_call_id: z.string(), turn_id: turnIdSchema, @@ -612,12 +531,6 @@ export const transcriptPlanEntrySchema = z.object({ review: transcriptPlanReviewSchema.optional(), }); -/** - * `GET /v1/sessions/{session_id}/transcript/plan` contract shape: the plans - * of one agent's ExitPlanMode calls, in timeline order. `tool_call_id` - * optional on the query: present narrows the read to that one call (unknown - * id → 40416), absent lists every call with recoverable plan content. - */ export const transcriptPlanResponseSchema = z.object({ agent_id: agentIdSchema, plans: z.array(transcriptPlanEntrySchema), diff --git a/packages/transcript/src/granularity/filterOps.ts b/packages/transcript/src/granularity/filterOps.ts index 7972159f8..82fd46801 100644 --- a/packages/transcript/src/granularity/filterOps.ts +++ b/packages/transcript/src/granularity/filterOps.ts @@ -23,23 +23,10 @@ function admits(grade: TranscriptGrade, op: TranscriptOperation): boolean { } } -/** - * Whether an op batch consists solely of `append` chunks — such batches are - * safe to mark volatile on the WS channel (droppable on backpressure: the client - * will hit an offset gap or a later flush and resynchronize). - */ export function isAppendOnly(ops: readonly TranscriptOperation[]): boolean { return ops.length > 0 && ops.every((op) => op.op === 'append'); } -/** - * Redact a reset snapshot to what the grade admits — the reset counterpart of - * `filterOpsForGrade` (live ops are filtered per grade; the reset must not - * leak the detail a lower grade never sees). Below 'block' the step/frame - * detail is stripped, leaving turn headers, standalone items, tasks, - * interactions and meta (exactly what 'turn' admits); 'block' and 'delta' - * carry the full snapshot, and 'off' never reaches here (no reset is sent). - */ export function redactSnapshotForGrade( grade: TranscriptGrade, snapshot: AgentTranscriptSnapshot, diff --git a/packages/transcript/src/granularity/grade.ts b/packages/transcript/src/granularity/grade.ts index bd383198d..a7e17d390 100644 --- a/packages/transcript/src/granularity/grade.ts +++ b/packages/transcript/src/granularity/grade.ts @@ -7,10 +7,6 @@ export const GRADE_RANK: Readonly> = { delta: 3, }; -/** - * Per-session subscription spec. Key `'*'` sets the default for all agents; - * explicit agent ids override it. Absent spec === everything 'off'. - */ export type TranscriptGradeSpec = Readonly>; export function gradeFor(spec: TranscriptGradeSpec | undefined, agentId: string): TranscriptGrade { @@ -18,18 +14,10 @@ export function gradeFor(spec: TranscriptGradeSpec | undefined, agentId: string) return spec[agentId] ?? spec['*'] ?? 'off'; } -/** Whether the transition needs the server to rebuild via reset snapshot. */ export function needsResetOnTransition(prev: TranscriptGrade, next: TranscriptGrade): boolean { return GRADE_RANK[next] > GRADE_RANK[prev]; } -/** - * Apply an agent-grained detach to a grade spec: each listed agent drops to - * an explicit 'off' (deleting the key would fall back to a non-off `'*'` - * default and keep streaming); a listed `'*'` deletes the wildcard entry - * instead. A spec with no remaining non-'off' entry collapses to `undefined` - * — the pure-legacy state. `undefined` in, `undefined` out (idempotent). - */ export function detachGrades( spec: TranscriptGradeSpec | undefined, agentIds: readonly string[], diff --git a/packages/transcript/src/model/attachment.ts b/packages/transcript/src/model/attachment.ts index 35a324716..ec7cba71e 100644 --- a/packages/transcript/src/model/attachment.ts +++ b/packages/transcript/src/model/attachment.ts @@ -1,11 +1,5 @@ import type { AttachmentId } from './ids'; -/** - * Where the frontend fetches the bytes. `file` addresses the process-global - * upload store; `session_media` addresses canonical media owned by the - * transcript's session. Inline base64 data is deliberately dropped rather - * than shipped over the transcript API. - */ export type AttachmentSource = | { readonly kind: 'url'; readonly url: string } | { readonly kind: 'file'; readonly fileId: string } @@ -13,12 +7,9 @@ export type AttachmentSource = export interface TranscriptAttachment { readonly attachmentId: AttachmentId; - /** e.g. 'image/png'. */ readonly mediaType: string; - /** Original filename, when known. */ readonly name?: string; readonly size?: number; readonly source?: AttachmentSource; - /** Inline position marker inside the carrier's text, e.g. '[Image #1]'. */ readonly placeholder?: string; } diff --git a/packages/transcript/src/model/frame.ts b/packages/transcript/src/model/frame.ts index d8e01e422..2f26341f5 100644 --- a/packages/transcript/src/model/frame.ts +++ b/packages/transcript/src/model/frame.ts @@ -7,24 +7,15 @@ export type FrameRef = { readonly frameId: FrameId; }; -/** Assistant / user visible text. L1 always holds the full text so far. */ export interface TextFrame { readonly kind: 'text'; readonly frameId: FrameId; readonly role: 'assistant' | 'user'; readonly text: string; - /** Attachments carried by this message (entities in `attachments`). */ readonly attachmentIds?: readonly AttachmentId[]; - /** - * For user-role inputs that are about a task — e.g. a background-task - * completion notification injected into the running step — the referenced - * task entity. The text is the point-in-time record; the ref links the - * live task. - */ readonly taskId?: TaskId; } -/** Model thinking chain. Same full-text invariant as TextFrame. */ export interface ThinkingFrame { readonly kind: 'thinking'; readonly frameId: FrameId; @@ -33,10 +24,6 @@ export interface ThinkingFrame { export type ToolFrameState = 'running' | 'done' | 'error'; -/** - * The latest progress update of a running tool call (`tool.progress`), - * overwrite semantics — only the newest rides the frame. - */ export interface ToolFrameProgress { readonly kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; readonly text?: string; @@ -47,7 +34,6 @@ export interface ToolFrameProgress { export interface AgentRef { readonly agentId: AgentId; - /** 'member' marks one child of an agent group (dynamic_workflow); default is 'child'. */ readonly role?: 'child' | 'member'; } @@ -55,43 +41,25 @@ export interface ToolCallFrame { readonly kind: 'tool'; readonly frameId: FrameId; readonly toolCallId: string; - /** Engine tool name, e.g. 'Read' / 'Bash' / 'Agent' / 'AgentDynamicWorkflow'. */ readonly name: string; - /** - * Optional view hint. Dispatch key at the view layer is `view ?? name`, so - * the server can suggest a renderer (e.g. 'dynamic_workflow') without a new frame kind. - */ readonly view?: string; readonly state: ToolFrameState; - /** Open content envelopes — opaque to this layer. */ readonly input?: unknown; readonly output?: unknown; readonly display?: unknown; readonly error?: string; - /** - * Raw argument text accumulated from `tool.call.delta`. `input` is the - * parsed object; this is the verbatim source text, kept after - * `tool.call.started` lands. - */ readonly inputText?: string; - /** Newest `tool.progress` update. */ readonly progress?: ToolFrameProgress; - /** Execution entity (backgroundable shell / subagent run) behind this call. */ readonly taskId?: TaskId; - /** Interaction (approval/question) that gated this call, if any. */ readonly approvalId?: InteractionId; - /** Todo entity this call mutates (TodoList writes). */ readonly todoId?: TodoId; - /** Agents spawned by this call (Agent tool / AgentDynamicWorkflow members). */ readonly agentRefs?: readonly AgentRef[]; } -/** Errors / warnings / informational notices attached to a step. */ export interface NoticeFrame { readonly kind: 'notice'; readonly frameId: FrameId; readonly level: 'error' | 'warning' | 'info'; - /** Origin subsystem, e.g. 'mcp', 'hook', 'compaction'. */ readonly source?: string; readonly message: string; readonly detail?: unknown; diff --git a/packages/transcript/src/model/ids.ts b/packages/transcript/src/model/ids.ts index c80c258e9..f9c6c35bd 100644 --- a/packages/transcript/src/model/ids.ts +++ b/packages/transcript/src/model/ids.ts @@ -23,7 +23,6 @@ export function frameId(step: StepId, ordinal: number): FrameId { return `${step}.f${ordinal}`; } -/** Compare turn ids by their embedded ordinal (`t2` < `t10`). */ export function compareTurnIds(a: TurnId, b: TurnId): number { return turnOrdinal(a) - turnOrdinal(b); } diff --git a/packages/transcript/src/model/interaction.ts b/packages/transcript/src/model/interaction.ts index d45f9e562..f91483c0f 100644 --- a/packages/transcript/src/model/interaction.ts +++ b/packages/transcript/src/model/interaction.ts @@ -13,16 +13,8 @@ export type InteractionState = export interface TranscriptInteraction { readonly interactionId: InteractionId; readonly interactionKind: InteractionKind; - /** - * The tool call this interaction was issued from — the timeline anchor. - * Present for the common case (approvals gate a tool call; questions are - * emitted by the AskUserQuestion tool call itself). Absent means the - * interaction is unanchored and renders floating rather than inline. - */ readonly toolCallId?: string; readonly state: InteractionState; - /** Open content: engine ApprovalRequest / QuestionRequest payload. */ readonly request?: unknown; - /** Open content: engine ApprovalResponse / QuestionResult payload. */ readonly response?: unknown; } diff --git a/packages/transcript/src/model/item.ts b/packages/transcript/src/model/item.ts index a92be03ab..509931697 100644 --- a/packages/transcript/src/model/item.ts +++ b/packages/transcript/src/model/item.ts @@ -1,11 +1,6 @@ import type { MarkerId, TaskId, TaskRefId } from './ids'; import type { TranscriptTurn } from './turn'; -/** - * Marker keys are namespaced strings. Well-known keys are listed in - * `KNOWN_MARKERS` for renderer dispatch documentation; `custom:` leaves - * the door open ("content open") without widening the item union. - */ export type MarkerKey = string; export const KNOWN_MARKERS = [ @@ -24,28 +19,14 @@ export const KNOWN_MARKERS = [ 'hook', ] as const; -/** - * A structural timeline annotation that does not belong to any step: - * compaction/undo/clear ribbons, goal updates (also mirrored into - * `meta.goal`), plan/dynamic_workflow mode transitions, skill activations, cron firing, - * hook results, and step-less notices (`marker: 'notice'` with a notice - * payload). - */ export interface TranscriptMarker { readonly kind: 'marker'; readonly markerId: MarkerId; readonly marker: MarkerKey; - /** Open content; interpreted by markerRenderers. */ readonly payload?: unknown; readonly at?: string; } -/** - * An inline reference to an execution entity in `tasks`. The entity itself is - * global (never paginated) — this placeholder keeps its position in the - * reading flow. Foreground→background (`!shell` detach) is just the task's - * `detached` flag flipping; the ref does not change. - */ export interface TranscriptTaskRef { readonly kind: 'taskref'; readonly refId: TaskRefId; diff --git a/packages/transcript/src/model/meta.ts b/packages/transcript/src/model/meta.ts index 211d88929..f680f6129 100644 --- a/packages/transcript/src/model/meta.ts +++ b/packages/transcript/src/model/meta.ts @@ -10,18 +10,12 @@ export interface GoalMeta { readonly budgetLimit?: number; } -/** Mode badges mirrored at session level. */ export interface ModesMeta { readonly plan?: { readonly reviewPath?: string; readonly version?: number }; readonly dynamic_workflow?: { readonly trigger?: string }; readonly tower?: Record; } -/** - * Contract shape of `modes` inside a `meta.merge` op: each key may be the mode - * object (set the badge) or `null` (the mode exited — clear it). An absent - * key keeps the prior state. - */ export interface ModesMetaMerge { readonly plan?: { readonly reviewPath?: string; readonly version?: number } | null; readonly dynamic_workflow?: { readonly trigger?: string } | null; @@ -30,14 +24,8 @@ export interface ModesMetaMerge { export type ActivityMeta = 'idle' | 'turn' | 'disposing' | 'unknown'; -/** Turn end reason inside the 'ended' phase; mirrors the wire `turnEndReasonSchema`. */ export type TurnEndReasonMeta = 'completed' | 'cancelled' | 'failed' | 'blocked'; -/** - * What the agent is doing right now. Same shape as the wire - * `agentPhaseSchema` (agent-gateway `protocol/events-zod.ts`), copied through - * opaquely — this package must not import the server. - */ export type AgentPhaseMeta = | { readonly kind: 'idle' } | { @@ -101,19 +89,12 @@ export type AgentPhaseMeta = readonly at: number; }; -/** Token usage slices of the agent status (the wire `UsageStatus` shape, verbatim). */ export interface AgentUsageMeta { readonly byModel?: Readonly>; readonly currentTurn?: StepUsage; readonly total?: StepUsage; } -/** - * Agent status projected from `agent.status.updated` / `agent.activity.updated`. - * Slices arrive piecemeal, so `meta.merge` shallow-merges this key one level - * deep (`{...old.agent, ...new.agent}`) — a whole-object replace would drop - * fields carried by earlier slices. - */ export interface AgentStatusMeta { readonly model?: string; readonly thinkingEffort?: string; @@ -132,7 +113,6 @@ export interface TranscriptMeta { readonly agent?: AgentStatusMeta; } -/** Contract shape of a `meta.merge` payload — like {@link TranscriptMeta}, but mode keys and `goal` may be `null` to clear. */ export type TranscriptMetaMerge = Omit & { readonly modes?: ModesMetaMerge; readonly goal?: GoalMeta | null; diff --git a/packages/transcript/src/model/prompt.ts b/packages/transcript/src/model/prompt.ts index 2cb3013ee..27379c305 100644 --- a/packages/transcript/src/model/prompt.ts +++ b/packages/transcript/src/model/prompt.ts @@ -11,12 +11,9 @@ export type TranscriptPromptStatus = export interface TranscriptPrompt { readonly promptId: PromptId; readonly status: TranscriptPromptStatus; - /** The user message this prompt materialized as, when it did. */ readonly userMessageId?: string; - /** Open content envelope (the engine's message content parts). */ readonly content?: unknown; readonly createdAt: string; readonly finishedAt?: string; - /** Set when the prompt was rerouted by a steer. */ readonly steeredAt?: string; } diff --git a/packages/transcript/src/model/task.ts b/packages/transcript/src/model/task.ts index 09861099d..de4caa8f7 100644 --- a/packages/transcript/src/model/task.ts +++ b/packages/transcript/src/model/task.ts @@ -15,26 +15,16 @@ export interface TranscriptTask { readonly taskId: TaskId; readonly kind: TaskKind; readonly state: TaskState; - /** Foreground→background transition: `!shell` detach, task tool backgrounding. */ readonly detached: boolean; - /** Human-readable one-liner (command line, agent description, …). */ readonly description?: string; - /** For kind 'subagent' / dynamic_workflow members: the spawned agent's transcript to subscribe. */ readonly agentId?: AgentId; - /** Tail of captured output; appended via `append { target: 'task' }`. */ readonly outputTail: string; readonly startedAt?: string; readonly endedAt?: string; - /** One-line result summary (`subagent.completed`). */ readonly resultSummary?: string; - /** Failure message (`subagent.failed`). */ readonly error?: string; - /** Why the task entered its current state (`subagent.suspended` reason). */ readonly stateReason?: string; - /** Token usage of the finished run (`subagent.completed`). */ readonly usage?: StepUsage; - /** Model the spawned subagent runs on (`subagent.spawned`). */ readonly model?: string; - /** Thinking effort of the spawned subagent (`subagent.spawned`). */ readonly thinkingEffort?: string; } diff --git a/packages/transcript/src/model/turn.ts b/packages/transcript/src/model/turn.ts index 8efda19aa..a0a0cbe58 100644 --- a/packages/transcript/src/model/turn.ts +++ b/packages/transcript/src/model/turn.ts @@ -1,10 +1,6 @@ import type { TranscriptFrame } from './frame'; import type { AttachmentId, StepId, TaskId, TurnId } from './ids'; -/** - * What triggered this turn. Drives `inputRenderers` at the view layer. The - * union is closed; per-origin detail rides in `payload` (open content). - */ export type TurnOrigin = | { kind: 'user'; payload?: unknown } | { kind: 'cron'; taskId?: TaskId; payload?: unknown } @@ -25,10 +21,6 @@ export interface TranscriptUsage { readonly cost?: number; } -/** - * Token usage of one LLM step. Same shape as the engine's `TokenUsage` wire - * payload — the server copies it through opaquely. - */ export interface StepUsage { readonly inputOther: number; readonly output: number; @@ -36,7 +28,6 @@ export interface StepUsage { readonly inputCacheCreation: number; } -/** LLM latency breakdown of one step; the wire may carry any subset. */ export interface StepTiming { readonly llmFirstTokenLatencyMs?: number; readonly llmStreamDurationMs?: number; @@ -46,11 +37,6 @@ export interface StepTiming { readonly llmClientConsumeMs?: number; } -/** - * A retry in flight on a running step. Set while retrying; the step's - * terminal upsert simply carries no `retry`, which clears it (step.upsert - * replaces the whole header). - */ export interface StepRetry { readonly failedAttempt: number; readonly nextAttempt: number; @@ -64,24 +50,16 @@ export interface StepRetry { export interface TranscriptTurn { readonly kind: 'turn'; readonly turnId: TurnId; - /** Per-agent monotonic ordinal; also the pagination cursor anchor. */ readonly ordinal: number; readonly state: TurnState; readonly origin: TurnOrigin; - /** The raw prompt that opened the turn (user text, cron prompt, …). */ readonly prompt?: string; - /** Attachments carried by the turn-opening input (entities in `attachments`). */ readonly attachmentIds?: readonly AttachmentId[]; readonly steps: TranscriptStep[]; readonly startedAt?: string; readonly endedAt?: string; readonly usage?: TranscriptUsage; - /** Wall-clock duration of the turn, set on terminal upserts (`turn.ended`). */ readonly durationMs?: number; - /** - * Terminal error message (`turn.ended.error`); the structured payload - * already rides the 'error' notice marker. - */ readonly error?: string; } @@ -94,13 +72,10 @@ export interface TranscriptStep { readonly frames: TranscriptFrame[]; readonly startedAt?: string; readonly endedAt?: string; - /** Token usage of this step's LLM call (`turn.step.completed`). */ readonly usage?: StepUsage; - /** Provider finish reason (`finishReason ?? rawFinishReason ?? providerFinishReason`). */ readonly finishReason?: string; readonly timing?: StepTiming; readonly retry?: StepRetry; - /** `turn.step.interrupted` reason / message. */ readonly endReason?: string; readonly endMessage?: string; } diff --git a/packages/transcript/src/ops/apply.ts b/packages/transcript/src/ops/apply.ts index 13192b103..dcbf900aa 100644 --- a/packages/transcript/src/ops/apply.ts +++ b/packages/transcript/src/ops/apply.ts @@ -16,22 +16,15 @@ import type { StepHeader, } from './operation'; -/** Mutable-free aggregate state behind one AgentTranscript. */ export interface AgentState { readonly items: readonly TranscriptItem[]; readonly tasks: ReadonlyMap; - /** Global interaction entities (approvals / questions), keyed by id. */ readonly interactions: ReadonlyMap; - /** Global attachment entities (media metadata), keyed by id. */ readonly attachments: ReadonlyMap; - /** Global todo documents (latest state), keyed by id. */ readonly todos: ReadonlyMap; - /** Global prompt queue entities, keyed by id. */ readonly prompts: ReadonlyMap; readonly meta: TranscriptMeta; - /** Interaction ids currently in 'pending' state (derived index). */ readonly pendingInteractions: ReadonlySet; - /** Set by windowed resets: older turns exist beyond the loaded window. */ readonly hasMoreOlder: boolean; } @@ -49,9 +42,7 @@ export const EMPTY_AGENT_STATE: AgentState = { export interface ApplyResult { readonly state: AgentState; - /** True when the op changed observable state. */ readonly changed: boolean; - /** Present when an append failed to land (offset beyond local length). */ readonly gap?: { readonly expected: number; readonly got: number }; } @@ -357,14 +348,6 @@ function applyTaskAppend(state: AgentState, op: AppendOp): ApplyResult { return { state: { ...state, tasks }, changed: true }; } -/** - * Offset placement, mirroring the web client's alignDelta semantics: - * `offset > local length` is a gap (caller should re-snapshot); a chunk that - * is already fully present is a duplicate (no change); a partially present - * chunk is trimmed to its novel suffix — but only when the overlap region - * agrees. A chunk behind local state whose overlap does NOT match is a gap - * too (diverged stream), never a silent rewrite that drops local content. - */ export function appendAtOffset( local: string, offset: number, diff --git a/packages/transcript/src/ops/operation.ts b/packages/transcript/src/ops/operation.ts index 1db8cbba4..b33dea199 100644 --- a/packages/transcript/src/ops/operation.ts +++ b/packages/transcript/src/ops/operation.ts @@ -15,9 +15,7 @@ import type { TranscriptTask } from '../model/task'; import type { TranscriptTodo } from '../model/todo'; import type { TranscriptStep, TranscriptTurn } from '../model/turn'; -/** Turn header as carried in ops: steps always arrive via step.upsert. */ export type TurnHeader = Omit; -/** Step header as carried in ops: frames always arrive via frame.upsert. */ export type StepHeader = Omit; export interface ResetOp { @@ -48,7 +46,6 @@ export type AppendTarget = | { readonly type: 'frame'; readonly turnId: TurnId; readonly stepId: StepId; readonly frameId: FrameId } | { readonly type: 'task'; readonly taskId: TaskId }; -/** The only non-idempotent op. `offset` is the chunk's cumulative position. */ export interface AppendOp { readonly op: 'append'; readonly target: AppendTarget; @@ -59,18 +56,12 @@ export interface AppendOp { export interface MarkerUpsertOp { readonly op: 'marker.upsert'; readonly item: TranscriptMarker; - /** - * Placement anchor for out-of-order (backfill) inserts: insert before the - * first turn with `ordinal >= beforeTurn` (appending when no such turn - * exists). Absent = append at the end — the live real-time order. - */ readonly beforeTurn?: number; } export interface TaskRefUpsertOp { readonly op: 'taskref.upsert'; readonly item: TranscriptTaskRef; - /** Same placement anchor as `MarkerUpsertOp.beforeTurn`. */ readonly beforeTurn?: number; } @@ -79,39 +70,21 @@ export interface TaskUpsertOp { readonly task: TranscriptTask; } -/** - * Interaction entity upsert — global like `task.upsert`, addressed by id - * (never placed into a step). Flows at 'turn' grade and up, so even coarse - * subscribers see pending approvals/questions. - */ export interface InteractionUpsertOp { readonly op: 'interaction.upsert'; readonly interaction: TranscriptInteraction; } -/** - * Attachment entity upsert — global, addressed by id. Media bytes never - * travel in ops; the entity carries metadata plus a fetch reference. - */ export interface AttachmentUpsertOp { readonly op: 'attachment.upsert'; readonly attachment: TranscriptAttachment; } -/** - * Todo document upsert — whole-document replace (idempotent). Carries the - * latest list; point-in-time history stays on `TodoList` tool frames. - */ export interface TodoUpsertOp { readonly op: 'todo.upsert'; readonly todo: TranscriptTodo; } -/** - * Prompt queue entity upsert — global like `task.upsert`, addressed by id - * (never placed into the timeline). Flows at 'turn' grade and up, so even - * coarse subscribers see queue state. - */ export interface PromptUpsertOp { readonly op: 'prompt.upsert'; readonly prompt: TranscriptPrompt; @@ -122,7 +95,6 @@ export interface MetaMergeOp { readonly meta: TranscriptMetaMerge; } -/** Structural correction (undo / clear). Removes whole items by id; idempotent. */ export interface ItemsRemoveOp { readonly op: 'items.remove'; readonly ids: readonly string[]; @@ -149,30 +121,19 @@ export interface TranscriptOpBatch { readonly ops: readonly TranscriptOperation[]; } -/** Full materialized state of one AgentTranscript, as used by `reset`. */ export interface AgentTranscriptSnapshot { readonly items: readonly TranscriptItem[]; readonly tasks: readonly TranscriptTask[]; - /** Global interaction entities (approvals / questions); never paginated. */ readonly interactions: readonly TranscriptInteraction[]; - /** Global attachment entities (media metadata); never paginated. */ readonly attachments: readonly TranscriptAttachment[]; - /** Global todo documents (latest state); never paginated. */ readonly todos: readonly TranscriptTodo[]; - /** Global prompt queue entities; never paginated. */ readonly prompts: readonly TranscriptPrompt[]; readonly meta: TranscriptMeta; - /** - * When the reset only ships a tail window, this flag tells the consumer - * older turns exist and must be paged in over REST. - */ readonly hasMoreOlder?: boolean; } export interface AppliedOps { - /** Ops that were accepted and mutated the store (normalized). */ readonly accepted: readonly TranscriptOperation[]; - /** Set when an `append` could not be placed (offset beyond local length). */ readonly gap?: { readonly target: AppendTarget; readonly expected: number; readonly got: number }; } diff --git a/packages/transcript/src/store/agentTranscript.ts b/packages/transcript/src/store/agentTranscript.ts index 94a075d98..0f7a28da3 100644 --- a/packages/transcript/src/store/agentTranscript.ts +++ b/packages/transcript/src/store/agentTranscript.ts @@ -32,16 +32,10 @@ export class AgentTranscript { constructor(readonly agentId: AgentId) {} - /** Full load == applying a reset: there is no second seeding path. */ receive(ops: readonly TranscriptOperation[]): AppliedOps { return this.apply(ops); } - /** - * The single convergence path. Returns the accepted ops plus a gap signal - * when an `append` could not land (the caller's policy decides to ignore or - * re-snapshot). Emits exactly one `onChange` batch when anything changed. - */ apply(ops: readonly TranscriptOperation[]): AppliedOps { const accepted: TranscriptOperation[] = []; let gap: AppliedOps['gap']; @@ -132,7 +126,6 @@ export class AgentTranscript { return this.#state.hasMoreOlder; } - /** Materialize current state (optionally windowed to the newest turns). */ snapshot(window?: { tailTurns: number }): AgentTranscriptSnapshot { let items = this.#state.items; let hasMoreOlder = this.#state.hasMoreOlder; diff --git a/packages/transcript/src/store/transcriptStore.ts b/packages/transcript/src/store/transcriptStore.ts index 04c08ffbb..af1a5f26f 100644 --- a/packages/transcript/src/store/transcriptStore.ts +++ b/packages/transcript/src/store/transcriptStore.ts @@ -3,7 +3,6 @@ import { AgentTranscript, type Disposable } from './agentTranscript'; export interface AgentDescriptor { readonly agentId: AgentId; - /** Engine metadata, mirrored for display (e.g. 'main' | 'sub' | dynamic_workflow member). */ readonly type?: 'main' | 'sub' | 'independent'; readonly parentAgentId?: AgentId; readonly label?: string; @@ -20,7 +19,6 @@ export class TranscriptStore { constructor(readonly sessionId: string) { } - /** Lazily create (or fetch) the transcript for an agent. */ ensureAgent(agentId: AgentId, descriptor?: AgentDescriptor): AgentTranscript { let transcript = this.#agents.get(agentId); if (!transcript) { @@ -38,14 +36,12 @@ export class TranscriptStore { return this.#agents.get(agentId); } - /** Drop an agent entirely (disposed sub-agent, dynamic_workflow member cleaned up). */ removeAgent(agentId: AgentId): boolean { const removed = this.#agents.delete(agentId); if (this.#descriptors.delete(agentId) || removed) this.#emitRoster(); return removed; } - /** Merge or replace an agent's roster descriptor. */ describeAgent(descriptor: AgentDescriptor): void { if (this.#descriptors.get(descriptor.agentId) !== descriptor) { this.#descriptors.set(descriptor.agentId, descriptor); diff --git a/packages/transcript/src/view/registry.ts b/packages/transcript/src/view/registry.ts index 415f476cc..dd280e5a8 100644 --- a/packages/transcript/src/view/registry.ts +++ b/packages/transcript/src/view/registry.ts @@ -31,19 +31,16 @@ export class ViewRegistry { this.#fallbackTool = options.fallbackTool; } - /** Key: view hint or tool name (`frame.view ?? frame.name`, lower-cased). */ registerTool(key: string, renderer: C): this { this.#toolRenderers.set(key.toLowerCase(), renderer); return this; } - /** Key: origin kind ('user' | 'cron' | 'task' | …). */ registerInput(originKind: string, renderer: C): this { this.#inputRenderers.set(originKind, renderer); return this; } - /** Key: marker key ('compaction' | 'goal' | 'notice' | …). */ registerMarker(marker: string, renderer: C): this { this.#markerRenderers.set(marker, renderer); return this; diff --git a/scripts/check-no-comments.mjs b/scripts/check-no-comments.mjs index 204133d37..fac6f76c2 100644 --- a/scripts/check-no-comments.mjs +++ b/scripts/check-no-comments.mjs @@ -6,110 +6,6 @@ const ROOT = path.resolve(import.meta.dirname, '..'); const PACKAGES = ['packages/agent-core-v2', 'packages/agent-gateway', 'packages/transcript']; const DIRS = ['src', 'test', 'scripts']; -const MEMBER_KINDS = new Set([ - ts.SyntaxKind.PropertySignature, - ts.SyntaxKind.MethodSignature, - ts.SyntaxKind.PropertyDeclaration, - ts.SyntaxKind.MethodDeclaration, - ts.SyntaxKind.GetAccessor, - ts.SyntaxKind.SetAccessor, - ts.SyntaxKind.Constructor, - ts.SyntaxKind.EnumMember, - ts.SyntaxKind.CallSignature, - ts.SyntaxKind.ConstructSignature, - ts.SyntaxKind.IndexSignature, -]); - -const DECL_KINDS = new Set([ - ts.SyntaxKind.InterfaceDeclaration, - ts.SyntaxKind.ClassDeclaration, - ts.SyntaxKind.TypeAliasDeclaration, - ts.SyntaxKind.EnumDeclaration, - ts.SyntaxKind.FunctionDeclaration, - ts.SyntaxKind.VariableStatement, - ts.SyntaxKind.ModuleDeclaration, -]); - -function hasExportModifier(node) { - return ( - ts.canHaveModifiers(node) && - (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword) - ); -} - -function isFunctionWithBody(node) { - return (ts.isFunctionLike(node) || ts.isArrowFunction(node)) && node.body !== undefined; -} - -function collectBinding(name, out) { - if (ts.isIdentifier(name)) out.add(name.text); - else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { - for (const el of name.elements) if (ts.isBindingElement(el)) collectBinding(el.name, out); - } -} - -function declaredNames(node, out) { - if (ts.isVariableStatement(node)) { - for (const d of node.declarationList.declarations) collectBinding(d.name, out); - } else if ( - (ts.isFunctionDeclaration(node) || - ts.isClassDeclaration(node) || - ts.isInterfaceDeclaration(node) || - ts.isTypeAliasDeclaration(node) || - ts.isEnumDeclaration(node) || - ts.isModuleDeclaration(node)) && - node.name - ) { - out.add(node.name.text); - } -} - -function computeKeptJSDocStarts(sf) { - const exportedNames = new Set(); - for (const stmt of sf.statements) { - if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { - for (const el of stmt.exportClause.elements) { - exportedNames.add((el.propertyName ?? el.name).text); - } - } - if (ts.isExportAssignment(stmt) && ts.isIdentifier(stmt.expression)) { - exportedNames.add(stmt.expression.text); - } - } - - const kept = new Set(); - - function visit(node, ctx) { - const exported = hasExportModifier(node); - let effectivelyExported = exported; - if (!effectivelyExported && node.parent && ts.isSourceFile(node.parent)) { - const names = new Set(); - declaredNames(node, names); - for (const n of names) { - if (exportedNames.has(n)) { - effectivelyExported = true; - break; - } - } - } - - if (effectivelyExported || (ctx && (MEMBER_KINDS.has(node.kind) || DECL_KINDS.has(node.kind)))) { - const docs = node.jsDoc; - if (Array.isArray(docs)) for (const d of docs) kept.add(d.getStart(sf)); - } - - let childCtx; - if (isFunctionWithBody(node)) childCtx = false; - else if (effectivelyExported) childCtx = true; - else childCtx = ctx; - - node.forEachChild((c) => visit(c, childCtx)); - } - - visit(sf, false); - return kept; -} - function collectLeaves(node, leaves, jsdocNodes) { if (ts.isJSDoc(node)) { jsdocNodes.push(node); @@ -152,7 +48,6 @@ function extractGapComments(gap, offset, out) { function checkFile(file) { const text = fs.readFileSync(file, 'utf8'); const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); - const keptStarts = computeKeptJSDocStarts(sf); const leaves = []; const jsdocNodes = []; @@ -161,6 +56,10 @@ function checkFile(file) { const comments = []; let cursor = 0; + if (text.startsWith('#!')) { + const nl = text.indexOf('\n'); + cursor = nl === -1 ? text.length : nl + 1; + } for (const leaf of leaves) { const s = leaf.getStart(sf); if (s > cursor) extractGapComments(text.slice(cursor, s), cursor, comments); @@ -177,14 +76,13 @@ function checkFile(file) { const key = `${c.pos}:${c.end}`; if (seen.has(key)) continue; seen.add(key); - if (c.jsdoc && keptStarts.has(c.pos)) continue; const line = text.slice(0, c.pos).split('\n').length; const snippet = text.slice(c.pos, Math.min(c.end, c.pos + 60)).replace(/\s+/g, ' '); if (/(?:oxlint|eslint)-disable/.test(snippet)) continue; const isDirective = /@ts-(expect-error|ignore|nocheck)|prettier-ignore|istanbul|c8 ignore/.test( snippet, ); - violations.push({ line, snippet, isDirective }); + violations.push({ line, snippet, isDirective, jsdoc: c.jsdoc }); } return violations; } @@ -201,7 +99,7 @@ for (const pkg of PACKAGES) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules') stack.push(p); - } else if (/\.(ts|tsx|mts)$/.test(e.name)) { + } else if (/\.(ts|tsx|mts|mjs)$/.test(e.name)) { files.push(p); } } @@ -217,8 +115,8 @@ for (const f of files) { const rel = path.relative(ROOT, f); if (v.isDirective) { console.error(`${rel}:${v.line}: tooling directives are not allowed — fix the underlying lint/type problem instead: ${v.snippet}`); - } else if (v.snippet.startsWith('/**')) { - console.error(`${rel}:${v.line}: JSDoc is only allowed on exported symbols: ${v.snippet}`); + } else if (v.jsdoc) { + console.error(`${rel}:${v.line}: JSDoc is not allowed in this package: ${v.snippet}`); } else { console.error(`${rel}:${v.line}: comments are not allowed in this package: ${v.snippet}`); } From 009254373a4996bacb830fd92558b6ba7fadb387 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:16:19 -0400 Subject: [PATCH 02/13] refactor(agent-core-v2): move dateChange to an agent runtime injection effect --- .../agent-core-v2/docs/state-manifest.d.ts | 9 +- .../src/agent/runtime/agentRuntimeSet.ts | 10 +- .../src/features/dateChange/dateChange.ts | 9 - .../dateChange/dateChangeAgentRuntime.ts | 177 ++++++++++++++++++ .../features/dateChange/dateChangeFeature.ts | 8 +- .../features/dateChange/dateChangeService.ts | 134 ------------- packages/agent-core-v2/src/index.ts | 2 +- .../agent/runtime/agentRuntimeSet.test.ts | 41 ++++ .../dateChange/dateChangeInjection.test.ts | 11 +- packages/agent-core-v2/test/harness/agent.ts | 11 -- 10 files changed, 236 insertions(+), 176 deletions(-) create mode 100644 packages/agent-core-v2/src/features/dateChange/dateChangeAgentRuntime.ts delete mode 100644 packages/agent-core-v2/src/features/dateChange/dateChangeService.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index e3c476f3d..2d7347df5 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 85 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 84 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -58,7 +58,6 @@ // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts -// dateChange.seed src/features/dateChange/dateChangeService.ts // dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts // externalHooks.stopHookContinuationUsed src/features/externalHooks/agent/agentExternalHooksService.ts // fullCompaction src/agent/fullCompaction/compactionOps.ts @@ -1448,12 +1447,6 @@ export interface AgentStateSnapshot { readonly parameters: Record; readonly disclosure?: 'deferred' | 'inline'; }>; - // src/features/dateChange/dateChangeService.ts - 'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/features/dateChange/dateChangeService.ts */ { - readonly localDate: string; - readonly timeZone: string; - readonly renderGeneration: number; - } | undefined; // src/features/dynamic_workflow/dynamicWorkflowOps.ts // replayable · durable — folds: DynamicWorkflowModeEnter, DynamicWorkflowModeExit 'dynamic_workflow': 'task' | 'tool' | 'manual' | null; diff --git a/packages/agent-core-v2/src/agent/runtime/agentRuntimeSet.ts b/packages/agent-core-v2/src/agent/runtime/agentRuntimeSet.ts index b051eb8e7..374a3de27 100644 --- a/packages/agent-core-v2/src/agent/runtime/agentRuntimeSet.ts +++ b/packages/agent-core-v2/src/agent/runtime/agentRuntimeSet.ts @@ -68,8 +68,11 @@ export class AgentRuntimeSet { restored: false, }; this.entries.set(descriptor.id, entry); - if (descriptor.durable !== undefined && this.durableHost !== undefined) { + if (this.durableHost === undefined) return; + if (descriptor.durable !== undefined) { this.attachDurableEntry(entry, this.durableHost); + } else if (descriptor.eager === true) { + this.runtime(entry); } } @@ -103,7 +106,10 @@ export class AgentRuntimeSet { if (this.closed) return; this.durableHost = host; for (const entry of this.entries.values()) { - if (entry.descriptor.durable === undefined) continue; + if (entry.descriptor.durable === undefined) { + if (entry.descriptor.eager === true) this.runtime(entry); + continue; + } this.attachDurableEntry(entry, host); } } diff --git a/packages/agent-core-v2/src/features/dateChange/dateChange.ts b/packages/agent-core-v2/src/features/dateChange/dateChange.ts index 80de56b78..b5f5cbbb6 100644 --- a/packages/agent-core-v2/src/features/dateChange/dateChange.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChange.ts @@ -1,15 +1,6 @@ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - export interface DateInjectionDisclosure { readonly kind: 'date'; readonly renderGeneration: number; readonly localDate: string; readonly timeZone: string; } - -export interface IAgentDateChangeService { - readonly _serviceBrand: undefined; -} - -export const IAgentDateChangeService: ServiceIdentifier = - createDecorator('agentDateChangeService'); diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeAgentRuntime.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeAgentRuntime.ts new file mode 100644 index 000000000..833a145f9 --- /dev/null +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeAgentRuntime.ts @@ -0,0 +1,177 @@ +import { assign, fromCallback, setup } from 'xstate'; + +import { IAgentProfileService } from '#/agent/profile/profile'; +import { + defineAgentRuntimeContract, + defineAgentRuntimeProvider, + type AgentRuntimeContext, + type AgentRuntimeRestoreEvent, +} from '#/agent/runtime/agentRuntime'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; +import type { + ContextInjectionContext, + ContextInjectionResult, +} from '#/features/reminder/types'; +import { IHostClock } from '#/os/interface/hostClock'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; + +import type { DateInjectionDisclosure } from './dateChange'; +import { pickDisclosureBaseline } from './disclosureBaseline'; + +const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; + +interface DateDisclosure { + readonly localDate: string; + readonly timeZone: string; + readonly renderGeneration: number; +} + +interface DateChangeActorContext { + readonly seed: DateDisclosure | undefined; + readonly runtime: AgentRuntimeContext; +} + +interface DateChangeDiscloseEvent { + readonly type: 'dateChange.disclose'; + readonly seed: DateDisclosure; +} + +function currentDateDisclosure(clock: IHostClock): Omit { + const date = clock.now(); + const timeZone = clock.timeZone(); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((candidate) => candidate.type === type)?.value ?? ''; + return { + localDate: `${part('year')}-${part('month')}-${part('day')}`, + timeZone, + }; +} + +const dateChangeInjection = fromCallback(({ + input, +}: { + input: { + readonly runtime: AgentRuntimeContext; + }; +}) => { + const runtime = input.runtime; + const reminder = runtime + .get(IAgentLifecycleService) + .resolve(runtime.agent, AgentReminder); + const profile = runtime.get(IAgentProfileService); + const clock = runtime.get(IHostClock); + const sessionContext = runtime.get(ISessionContext); + const belongsToCurrentCwd = (): boolean => { + const environment = profile.data().environmentDisclosure; + return !( + environment !== undefined && + environment.cwd !== '' && + environment.cwd !== sessionContext.cwd + ); + }; + const dateFromProfile = (): DateDisclosure | undefined => { + if (!belongsToCurrentCwd()) return undefined; + const profileData = profile.data(); + const date = profileData.environmentDisclosure?.date; + if (!date?.disclosed) return undefined; + return { + ...date.value, + renderGeneration: profileData.renderGeneration ?? 0, + }; + }; + const registration = reminder.register( + DATE_CHANGE_INJECTION_VARIANT, + ({ + lastDisclosure, + }: ContextInjectionContext): ContextInjectionResult | undefined => { + const profileData = profile.data(); + if (!belongsToCurrentCwd()) return undefined; + const renderGeneration = profileData.renderGeneration ?? 0; + const current = currentDateDisclosure(clock); + const profileDate = dateFromProfile(); + const seed = runtime.getLogicState().seed; + const baseline = pickDisclosureBaseline( + lastDisclosure, + profileDate, + seed, + ); + if (baseline !== undefined && baseline.localDate !== current.localDate) { + return { + content: `The date has changed. Today's date is now ${current.localDate}. Rely on this reminder over any earlier date statement for the current date. DO NOT mention this to the user explicitly.`, + disclosure: { + kind: 'date', + renderGeneration, + localDate: current.localDate, + timeZone: current.timeZone, + }, + }; + } + if (lastDisclosure !== undefined || profileDate !== undefined) return undefined; + if (seed === undefined) { + runtime.send({ + type: 'dateChange.disclose', + seed: { ...current, renderGeneration }, + }); + } + return { + content: `Today's date is ${current.localDate}. The current date is restated in a reminder whenever it changes; rely on the latest such reminder for the current date. DO NOT mention this to the user explicitly.`, + disclosure: { + kind: 'date', + renderGeneration, + localDate: current.localDate, + timeZone: current.timeZone, + }, + }; + }, + ); + return () => { registration.dispose(); }; +}); + +const dateChangeActorLogic = setup({ + types: {} as { + context: DateChangeActorContext; + input: AgentRuntimeContext; + events: DateChangeDiscloseEvent | AgentRuntimeRestoreEvent; + }, + actors: { dateChangeInjection }, +}).createMachine({ + context: ({ input }) => ({ seed: undefined, runtime: input }), + initial: 'beforeRestore', + states: { + beforeRestore: { + on: { 'runtime.restore': 'active' }, + }, + active: { + invoke: { + src: 'dateChangeInjection', + input: ({ context }) => ({ runtime: context.runtime }), + }, + }, + }, + on: { + 'dateChange.disclose': { + actions: assign({ seed: ({ event }) => event.seed }), + }, + }, +}); + +export class DateChangeRuntime {} + +export const AgentDateChange = defineAgentRuntimeContract('dateChange'); + +export const dateChangeAgentRuntimeProvider = defineAgentRuntimeProvider( + AgentDateChange, + { + id: 'dateChange', + logic: dateChangeActorLogic, + eager: true, + createApi: () => new DateChangeRuntime(), + }, +); diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts index ea336a947..4461c0493 100644 --- a/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeFeature.ts @@ -1,18 +1,14 @@ -import { ScopeActivation } from '#/_base/di/instantiation'; import { Feature } from '#/features/feature'; import { registerFeature } from '#/features/featureRegistry'; -import { IAgentDateChangeService } from './dateChange'; -import { AgentDateChangeService } from './dateChangeService'; +import { dateChangeAgentRuntimeProvider } from './dateChangeAgentRuntime'; export class DateChangeFeature extends Feature { static override readonly name = 'dateChange'; constructor() { super(); - this.contributeAgentService(IAgentDateChangeService, AgentDateChangeService, { - activation: ScopeActivation.OnScopeCreated, - }); + this.contributeAgentRuntime(dateChangeAgentRuntimeProvider); } } diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts deleted file mode 100644 index 9c138eb98..000000000 --- a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { defineState } from '#/state/state'; -import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; -import type { - ContextInjectionContext, - ContextInjectionResult, -} from '#/features/reminder/types'; -import { pickDisclosureBaseline } from './disclosureBaseline'; -import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IHostClock } from '#/os/interface/hostClock'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; - -import { type DateInjectionDisclosure, IAgentDateChangeService } from './dateChange'; - -const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; - -export const dateChangeSeedKey = defineState( - 'dateChange.seed', - () => undefined, -); - -export class AgentDateChangeService extends Disposable implements IAgentDateChangeService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAgentProfileService private readonly profile: IAgentProfileService, - @IAgentStateService private readonly states: IAgentStateService, - @IHostClock private readonly clock: IHostClock, - @ISessionContext private readonly sessionContext: ISessionContext, - ) { - super(); - this._register(this.states.contributeState(dateChangeSeedKey)); - this._register( - activateReminderWhenReady(agentLifecycle, scopeContext, (reminder) => - reminder.register( - DATE_CHANGE_INJECTION_VARIANT, - (ctx) => this.reminder(ctx), - ), - ), - ); - } - - private reminder({ - lastDisclosure, - }: ContextInjectionContext): ContextInjectionResult | undefined { - const profileData = this.profile.data(); - const environment = profileData.environmentDisclosure; - if ( - environment !== undefined && - environment.cwd !== '' && - environment.cwd !== this.sessionContext.cwd - ) { - return undefined; - } - const renderGeneration = profileData.renderGeneration ?? 0; - const current = currentDateDisclosure(this.clock); - const profileDate = this.dateFromProfile(); - const baseline = pickDisclosureBaseline( - lastDisclosure, - profileDate, - this.states.get(dateChangeSeedKey), - ); - if (baseline !== undefined && baseline.localDate !== current.localDate) { - return { - content: `The date has changed. Today's date is now ${current.localDate}. Rely on this reminder over any earlier date statement for the current date. DO NOT mention this to the user explicitly.`, - disclosure: { - kind: 'date', - renderGeneration, - localDate: current.localDate, - timeZone: current.timeZone, - }, - }; - } - if (lastDisclosure !== undefined || profileDate !== undefined) return undefined; - if (this.states.get(dateChangeSeedKey) === undefined) { - this.states.set(dateChangeSeedKey, { ...current, renderGeneration }); - } - return { - content: `Today's date is ${current.localDate}. The current date is restated in a reminder whenever it changes; rely on the latest such reminder for the current date. DO NOT mention this to the user explicitly.`, - disclosure: { - kind: 'date', - renderGeneration, - localDate: current.localDate, - timeZone: current.timeZone, - }, - }; - } - - private dateFromProfile(): DateDisclosure | undefined { - const profileData = this.profile.data(); - const environment = profileData.environmentDisclosure; - if ( - environment !== undefined && - environment.cwd !== '' && - environment.cwd !== this.sessionContext.cwd - ) { - return undefined; - } - const date = environment?.date; - if (!date?.disclosed) return undefined; - return { - ...date.value, - renderGeneration: profileData.renderGeneration ?? 0, - }; - } -} - -interface DateDisclosure { - readonly localDate: string; - readonly timeZone: string; - readonly renderGeneration: number; -} - -function currentDateDisclosure(clock: IHostClock): Omit { - const date = clock.now(); - const timeZone = clock.timeZone(); - const parts = new Intl.DateTimeFormat('en-US', { - timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(date); - const part = (type: Intl.DateTimeFormatPartTypes): string => - parts.find((candidate) => candidate.type === type)?.value ?? ''; - return { - localDate: `${part('year')}-${part('month')}-${part('day')}`, - timeZone, - }; -} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index bc8df4700..7f2d15e51 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -643,7 +643,7 @@ export * from '#/features/reminder/systemReminder'; export * from '#/features/reminder/types'; import '#/features/reminder/reminderFeature'; export * from '#/features/dateChange/dateChange'; -export * from '#/features/dateChange/dateChangeService'; +export * from '#/features/dateChange/dateChangeAgentRuntime'; export * from '#/agent/contextProjector/contextProjector'; export * from '#/agent/contextProjector/contextProjectorService'; export * from '#/agent/contextProjector/mediaProjection'; diff --git a/packages/agent-core-v2/test/agent/runtime/agentRuntimeSet.test.ts b/packages/agent-core-v2/test/agent/runtime/agentRuntimeSet.test.ts index 68f110dda..625b4e6b6 100644 --- a/packages/agent-core-v2/test/agent/runtime/agentRuntimeSet.test.ts +++ b/packages/agent-core-v2/test/agent/runtime/agentRuntimeSet.test.ts @@ -142,6 +142,47 @@ describe('AgentRuntimeSet', () => { expect(attach.mock.results[0]!.value.dispose).toHaveBeenCalledTimes(1); }); + it('materializes an eager non-durable runtime at durable attach while a lazy one stays registered', async () => { + let restores = 0; + const eager = defineAgentRuntimeContract('eager-plain'); + const eagerProvider = defineAgentRuntimeProvider(eager, { + id: 'eager-plain', + logic: fromCallback(({ receive }) => { + receive((event) => { + if ((event as AgentRuntimeRestoreEvent).type === 'runtime.restore') restores += 1; + }); + }), + eager: true, + createApi: () => ({}), + }); + const lazy = defineAgentRuntimeContract('lazy-plain'); + const lazyProvider = defineAgentRuntimeProvider(lazy, { + id: 'lazy-plain', + logic: fromCallback(() => {}), + createApi: () => ({}), + }); + const set = new AgentRuntimeSet(agent, accessor); + set.apply({ definition: eager, provider: eagerProvider, generation: 1, active: true }); + set.apply({ definition: lazy, provider: lazyProvider, generation: 1, active: true }); + + expect(set.inspect()).toEqual([ + expect.objectContaining({ id: 'eager-plain', status: 'registered' }), + expect.objectContaining({ id: 'lazy-plain', status: 'registered' }), + ]); + + set.attachDurable(host(vi.fn(() => ({ dispose: vi.fn() })))); + + expect(set.inspect()).toEqual([ + expect.objectContaining({ id: 'eager-plain', status: 'materialized' }), + expect.objectContaining({ id: 'lazy-plain', status: 'registered' }), + ]); + + await set.restore(); + + expect(restores).toBe(1); + await set.close(); + }); + it('sends restore once and waits for actor readiness', async () => { let restores = 0; let release!: () => void; diff --git a/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts index 6b24f7417..b8c8e10e1 100644 --- a/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts +++ b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts @@ -8,12 +8,14 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentStateService } from '#/agent/state/agentState'; import { DEFAULT_AGENT_PROFILE_NAME, type EnvironmentDisclosureSnapshot, } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { dateChangeSeedKey } from '#/features/dateChange/dateChangeService'; +import { + AgentDateChange, + DateChangeRuntime, +} from '#/features/dateChange/dateChangeAgentRuntime'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -98,7 +100,7 @@ function messageText(message: ContextMessage): string { .join(''); } -describe('AgentDateChangeService', () => { +describe('dateChangeAgentRuntime', () => { let ctx: TestAgentContext; let context: IAgentContextMemoryService; let clock: TestHostClock; @@ -467,10 +469,9 @@ describe('AgentDateChangeService', () => { }); it('keeps one provider registration across repeated runtime restore', async () => { - const states = ctx.get(IAgentStateService); updateSystemPromptWithoutDate(profile, ctx.get(ISessionContext).cwd); - expect(states.has(dateChangeSeedKey)).toBe(true); + expect(ctx.resolve(AgentDateChange)).toBeInstanceOf(DateChangeRuntime); await runWillBeginStepHooks(loop); expect(dateReminders(context)).toHaveLength(1); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 72f17eca5..2f24d55e0 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -29,7 +29,6 @@ import '#/features/reminder/reminderFeature'; import { BUILTIN_REPLAYABLE_STATE_KEYS } from '../state/builtinReplayableKeys'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { AgentCron } from '#/features/cron/cronAgentRuntime'; -import { AgentDateChangeService } from '#/features/dateChange/dateChangeService'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { AgentGoal } from '#/features/goal/goalAgentRuntime'; import { IGoalDeadlineScheduler } from '#/features/goal/goalDeadlineScheduler'; @@ -1409,16 +1408,6 @@ export class AgentTestContext { reassertServiceOverrides(this.serviceOverrides, 'agent', this.agent.instantiation); this.initializeRestorableServices(); - this.disposables.push( - new AgentDateChangeService( - this.session.accessor.get(IAgentLifecycleService), - this.get(IAgentScopeContext), - this.get(IAgentProfileService), - this.get(IAgentStateService), - this.get(IHostClock), - this.get(ISessionContext), - ), - ); this.get(IAgentActivityView); const eventBus = this.get(IEventBus); From 08edb4407fc040f29893e36a4e0573164e5603fc Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:16:19 -0400 Subject: [PATCH 03/13] feat(agent-core-v2): add PYTHINKER_CODE_INFINITE_RETRY retry mode --- docs/configuration/env-vars.md | 3 +- .../agent-core-v2/src/_base/utils/retry.ts | 8 +- .../agent/llmRequester/llmRequesterService.ts | 44 ++++++- .../fullCompaction/fullCompaction.test.ts | 53 ++++++++ .../llmRequester/llmRequesterService.test.ts | 124 +++++++++++++++++- .../test/agent/stepRetry/stepRetry.test.ts | 81 ++++++++++++ 6 files changed, 306 insertions(+), 7 deletions(-) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index c8f50cc57..d5b901e2b 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -143,6 +143,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `PYTHINKER_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | | `PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP` | Maximum total attempts for a failing step (including the initial attempt); takes higher priority than `[loop_control] max_attempts_per_step` in `config.toml` (default `10`). The deprecated `PYTHINKER_LOOP_MAX_RETRIES_PER_STEP` is still honored with a warning when this variable is unset | Non-negative integer; invalid values are ignored | +| `PYTHINKER_CODE_INFINITE_RETRY` | Retry every failed LLM request indefinitely — turn steps and background operations such as compaction alike — instead of failing the task; waits use exponential backoff (capped at 32 s) and honor the server's `Retry-After` header, and aborting still cancels immediately. Intended for long-running unattended evaluations against endpoints that may fail temporarily | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_TOKEN_COUNTING_STRATEGY` | Which context token count is reported externally (the context-size display); takes higher priority than `[token_counting] strategy` in `config.toml` (default `measured+estimated`) | `measured+estimated`, `measured`, `estimated` (case-insensitive); invalid values are ignored | | `PYTHINKER_WEB_SEARCH_BASE_URL` | API URL of the web search (`WebSearch`) service; takes higher priority than `[services.pymodel_search] base_url` in `config.toml`, and enables the service without that config section. Persisted credentials and custom headers are not forwarded to an env-selected endpoint | Non-blank string; blank values are ignored | | `PYTHINKER_WEB_SEARCH_API_KEY` | API key of the web search (`WebSearch`) service; replaces both the configured API key and OAuth credential when set | Non-blank string; blank values are ignored | @@ -159,7 +160,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `PYTHINKER_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `PYTHINKER_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | -The three `PYTHINKER_CODE_IDENTITY_*` / `PYTHINKER_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `pythinker` / `pythinker -p` path selected with `PYTHINKER_CODE_LEGACY_FLAG=1` ignores them. +The `PYTHINKER_CODE_INFINITE_RETRY`, `PYTHINKER_CODE_IDENTITY_*`, and `PYTHINKER_CODE_BUILTIN_PRODUCT_SKILLS` variables are read by the default `agent-core-v2` engine. The legacy `pythinker` / `pythinker -p` path selected with `PYTHINKER_CODE_LEGACY_FLAG=1` ignores them. ## Diagnostic logs diff --git a/packages/agent-core-v2/src/_base/utils/retry.ts b/packages/agent-core-v2/src/_base/utils/retry.ts index ed4132f7d..120f0c1e1 100644 --- a/packages/agent-core-v2/src/_base/utils/retry.ts +++ b/packages/agent-core-v2/src/_base/utils/retry.ts @@ -13,12 +13,16 @@ export interface RetryErrorFields { readonly statusCode?: number; } +export function retryBackoffDelay(attemptIndex: number): number { + const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, attemptIndex), MAX_DELAY_MS); + return base + Math.random() * JITTER_FACTOR * base; +} + export function retryBackoffDelays(maxAttempts: number): number[] { const count = Math.max(maxAttempts - 1, 0); const delays: number[] = []; for (let i = 0; i < count; i += 1) { - const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, i), MAX_DELAY_MS); - delays.push(base + Math.random() * JITTER_FACTOR * base); + delays.push(retryBackoffDelay(i)); } return delays; } diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index db4a6003b..c96155095 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -17,6 +17,7 @@ import { IAgentMediaResolverService } from '#/agent/media/mediaResolver'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IConfigService } from '#/app/config/config'; import { + APIContextOverflowError, APIRequestTooLargeError, APIStatusError, APITimeoutError, @@ -73,8 +74,15 @@ import { type LlmRequestToolSchema, } from './llmRequestOps'; import { isAbortError, linkAbortSignal } from '#/_base/utils/abort'; +import { parseBooleanEnv } from '#/_base/utils/env'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; -import { retryErrorFields } from '#/_base/utils/retry'; +import { + readRetryAfterMs, + retryBackoffDelay, + retryErrorFields, + sleepForRetry, +} from '#/_base/utils/retry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; const EMPTY_TOOL_PARAMETERS: Record = { type: 'object', @@ -86,6 +94,7 @@ const noopOnPart: AgentLLMRequestPartHandler = () => {}; const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 180_000; const STREAM_STALL_REASON = { reason: 'llm-stream-idle-timeout' }; +export const PYTHINKER_CODE_INFINITE_RETRY_ENV = 'PYTHINKER_CODE_INFINITE_RETRY'; interface ResolvedLLMRequest { readonly requester: ModelRequester; @@ -162,6 +171,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, + @IBootstrapService private readonly bootstrap: IBootstrapService, ) { this.states.contributeState(llmRequestTraceKey); this.states.contributeState(llmRequesterLastConfigLogSignatureKey); @@ -456,6 +466,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; }; + let infiniteRetryAttempt = 0; for (;;) { try { return await run(policy); @@ -467,12 +478,39 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { signal, captureMediaStripPolicy, ); - if (nextPolicy === undefined) throw error; - policy = nextPolicy; + if (nextPolicy !== undefined) { + policy = nextPolicy; + continue; + } + const raw = unwrapErrorCause(error); + if ( + !this.infiniteRetryEnabled || + isAbortError(error) || + signal?.aborted === true || + raw instanceof APIContextOverflowError + ) { + throw error; + } + infiniteRetryAttempt += 1; + const delayMs = + readRetryAfterMs(raw) ?? + retryBackoffDelay(infiniteRetryAttempt - 1); + this.log.warn('llm request failed; retrying indefinitely (PYTHINKER_CODE_INFINITE_RETRY)', { + model: request.model.name, + ...request.logFields, + attempt: infiniteRetryAttempt, + delayMs, + ...retryErrorFields(error), + }); + await sleepForRetry(delayMs, signal); } } } + private get infiniteRetryEnabled(): boolean { + return parseBooleanEnv(this.bootstrap.getEnv(PYTHINKER_CODE_INFINITE_RETRY_ENV)) === true; + } + private nextProjectionPolicyForError( error: unknown, policy: ProjectionPolicy | undefined, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 7c9f30d9c..ad06f05fd 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -666,6 +666,59 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('retries any compaction request error indefinitely when PYTHINKER_CODE_INFINITE_RETRY is set', async () => { + vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1'); + let attempts = 0; + const generate: GenerateFn = async () => { + attempts += 1; + if (attempts === 1) throw new APIStatusError(400, 'endpoint broken', null, 1); + if (attempts === 2) throw new APIStatusError(404, 'model not found', null, 1); + return textResult('Recovered compacted summary.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + const compacted = ctx.once('full_compaction.complete'); + const completed = ctx.once('compaction.completed'); + + await ctx.rpc.beginCompaction({}); + await compacted; + await completed; + + expect(attempts).toBe(3); + await ctx.expectResumeMatches(); + }); + + it('lets context overflow reach compaction shrink instead of retrying when PYTHINKER_CODE_INFINITE_RETRY is set', async () => { + vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1'); + let attempts = 0; + const generate: GenerateFn = async () => { + attempts += 1; + if (attempts === 1) throw new APIContextOverflowError(400, 'context length exceeded'); + return textResult('Recovered compacted summary.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + const compacted = ctx.once('full_compaction.complete'); + const completed = ctx.once('compaction.completed'); + + await ctx.rpc.beginCompaction({}); + await compacted; + await completed; + + expect(attempts).toBe(2); + await ctx.expectResumeMatches(); + }); + it('recovers from an image-format rejection with a media-stripped resend', async () => { let attempts = 0; let sawMedia = false; diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index ca561fd81..e6ea20eeb 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -13,8 +13,9 @@ import { type ProjectionPolicy, } from '#/agent/contextProjector/contextProjector'; import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService'; -import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService'; +import { AgentLLMRequesterService, PYTHINKER_CODE_INFINITE_RETRY_ENV } from '#/agent/llmRequester/llmRequesterService'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -29,7 +30,10 @@ import type { Event2 } from '#/app/event/event2'; import { IEventBus } from '#/app/event/eventBus'; import { APIConnectionError, + APIContextOverflowError, APIEmptyResponseError, + APIProviderQuotaExhaustedError, + APIProviderRateLimitError, APIRequestTooLargeError, APIStatusError, APITimeoutError, @@ -56,6 +60,7 @@ import { Error2, ErrorCodes } from '#/errors'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { WireRecord } from '#/wire/record'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubBootstrap } from '../../app/bootstrap/stubs'; import { recordingWireLog, @@ -163,9 +168,11 @@ function createService( readonly mediaResolver?: Partial; readonly contextMessages?: Message[]; readonly llmConfig?: LlmConfig; + readonly env?: Record; } = {}, ) { const ix = disposables.add(new TestInstantiationService()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/pythinker-code-llm-requester-test', options.env ?? {})); const thinkingLevel = options.thinkingLevel ?? 'off'; const profile: Partial = { resolveModelContext: () => ({ @@ -357,6 +364,121 @@ describe('AgentLLMRequesterService strict resend', () => { }); }); +describe('AgentLLMRequesterService infinite retry', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries every request error while PYTHINKER_CODE_INFINITE_RETRY is set', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'), [ + new APIStatusError(404, 'model not found'), + new APIConnectionError('socket hang up'), + new APIProviderQuotaExhaustedError('quota exhausted'), + ]); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + + const promise = service.request(); + await vi.runAllTimersAsync(); + const finish = await promise; + + expect(calls.value).toBe(5); + expect(finish.message.content).toEqual([{ type: 'text', text: 'ok' }]); + }); + + it('honors the provider retry-after delay while retrying indefinitely', async () => { + const calls = { value: 0 }; + const requester = createRequester(calls, new APIProviderRateLimitError('slow down', null, 1)); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + + const startedAt = Date.now(); + await service.request(); + + expect(calls.value).toBe(2); + expect(Date.now() - startedAt).toBeLessThan(500); + }); + + it('stops retrying when the caller aborts during the backoff wait', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken')); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error('stop')), 100); + + const promise = service.request({}, undefined, controller.signal); + const assertion = expect(promise).rejects.toThrow('stop'); + await vi.runAllTimersAsync(); + await assertion; + + expect(calls.value).toBe(1); + }); + + it('keeps deterministic projection recovery ahead of infinite retry', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester(calls, new APIRequestTooLargeError(413, 'Request Entity Too Large')); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + + await service.request(); + + expect(calls.value).toBe(2); + }); + + it('lets context overflow reach deterministic recovery instead of retrying', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester( + calls, + new APIContextOverflowError(400, 'context length exceeded'), + ); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + + await expect(service.request()).rejects.toBeInstanceOf(APIContextOverflowError); + expect(calls.value).toBe(1); + }); + + it('retries operation requests indefinitely', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken'), [ + new APIStatusError(404, 'model not found'), + ]); + const { service } = createService(requester, undefined, { + env: { [PYTHINKER_CODE_INFINITE_RETRY_ENV]: '1' }, + }); + + const promise = service.request({ + source: { type: 'operation', requestKind: 'full_compaction' }, + }); + await vi.runAllTimersAsync(); + await promise; + + expect(calls.value).toBe(3); + }); + + it('does not retry when the switch is unset', async () => { + vi.useFakeTimers(); + const calls = { value: 0 }; + const requester = createRequester(calls, new APIStatusError(400, 'endpoint broken')); + const { service } = createService(requester, undefined); + + await expect(service.request()).rejects.toMatchObject({ statusCode: 400 }); + expect(calls.value).toBe(1); + }); +}); + describe('AgentLLMRequesterService media-stripped resend', () => { const IMAGE_FORMAT_400 = new APIStatusError( 400, diff --git a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts index 39de3c943..3e4b92fa4 100644 --- a/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts +++ b/packages/agent-core-v2/test/agent/stepRetry/stepRetry.test.ts @@ -26,6 +26,7 @@ describe('stepRetry plugin', () => { await ctx.expectResumeMatches(); } finally { await ctx.dispose(); + vi.unstubAllEnvs(); } }); @@ -288,6 +289,86 @@ describe('stepRetry plugin', () => { const second = await runTurn(2); expect(second).toEqual({ type: 'completed', steps: 1, truncated: false }); }); + + it('retries any request error inside the request when PYTHINKER_CODE_INFINITE_RETRY is set', async () => { + vi.useFakeTimers(); + vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1'); + let calls = 0; + ctx = createTestAgent( + llmGenerateServices(async () => { + calls += 1; + if (calls === 1) throw new APIStatusError(400, 'endpoint broken'); + if (calls === 2) throw new APIStatusError(404, 'model not found'); + if (calls === 3) throw new APIStatusError(429, 'slow down'); + return { + id: 'infinite-retry-response', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }), + ); + + const result = await runTurn(1); + + expect(result).toEqual({ type: 'completed', steps: 1, truncated: false }); + expect(calls).toBe(4); + expect(rpcEvents('turn.step.retrying')).toEqual([]); + expect(rpcEvents('turn.step.interrupted')).toEqual([]); + }); + + it('keeps retrying past the per-step attempt budget when PYTHINKER_CODE_INFINITE_RETRY is set', async () => { + vi.useFakeTimers(); + vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1'); + let calls = 0; + ctx = createTestAgent( + llmGenerateServices(async () => { + calls += 1; + if (calls <= 12) throw new APIStatusError(429, 'slow down'); + return { + id: 'infinite-retry-response', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed', + rawFinishReason: 'stop', + }; + }), + ); + + const result = await runTurn(1); + + expect(result).toEqual({ type: 'completed', steps: 1, truncated: false }); + expect(calls).toBe(13); + expect(rpcEvents('turn.step.retrying')).toEqual([]); + }); + + it('cancels the turn when aborted during an infinite retry backoff', async () => { + vi.useFakeTimers(); + vi.stubEnv('PYTHINKER_CODE_INFINITE_RETRY', '1'); + const controller = new AbortController(); + let calls = 0; + ctx = createTestAgent( + llmGenerateServices(async () => { + calls += 1; + throw new APIStatusError(400, 'endpoint broken'); + }), + ); + setTimeout(() => controller.abort(new Error('stop')), 100); + + const result = await runTurn(1, controller.signal); + + expect(result.type).toBe('cancelled'); + expect(calls).toBe(1); + }); }); describe('retryBackoffDelays', () => { From 5bcd3cc0a21cfd8880c31e9d627a770c03754d5a Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:16:19 -0400 Subject: [PATCH 04/13] fix(agent-core-v2): keep abort listeners under the listener ceiling --- .changeset/abort-signal-listener-ceiling.md | 5 +++++ .../src/agent/loop/loopService.ts | 4 ++++ .../workspaceFs/internal/fsProcess.ts | 16 ++++++++++------ .../agent-core-v2/test/agent/loop/loop.test.ts | 18 ++++++++++++++++++ .../workspace/workspaceFs/fsProcess.test.ts | 7 +++++++ 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 .changeset/abort-signal-listener-ceiling.md diff --git a/.changeset/abort-signal-listener-ceiling.md b/.changeset/abort-signal-listener-ceiling.md new file mode 100644 index 000000000..e172a0c92 --- /dev/null +++ b/.changeset/abort-signal-listener-ceiling.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Silence the MaxListenersExceededWarning that could appear during long agent turns with many parallel tool calls. diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 9a2e3b780..c1273046c 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; import { createControlledPromise } from '@antfu/utils'; @@ -82,6 +83,8 @@ export const loopLastRequestTraceIdKey = defineState( ); export const loopDisposingKey = defineState('loop.disposing', () => false); +const MAX_STEP_SIGNAL_LISTENERS = 64; + export class AgentLoopService extends Disposable implements IAgentLoopService { declare readonly _serviceBrand: undefined; @@ -702,6 +705,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ? runtime.turnSignal : AbortSignal.any([runtime.turnSignal, mutableStep.controller.signal]), }; + EventEmitter.setMaxListeners(MAX_STEP_SIGNAL_LISTENERS, step.signal); this.materializeBatch(batch); return { step }; } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/internal/fsProcess.ts b/packages/agent-core-v2/src/workspace/workspaceFs/internal/fsProcess.ts index 669f33c80..c51f795e8 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/internal/fsProcess.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/internal/fsProcess.ts @@ -35,12 +35,16 @@ export async function runCommand( else signal.addEventListener('abort', onAbort, { once: true }); } - const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout), - readStream(proc.stderr), - proc.wait().catch(() => -1), - ]); - return { exitCode, stdout, stderr }; + try { + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(proc.stdout), + readStream(proc.stderr), + proc.wait().catch(() => -1), + ]); + return { exitCode, stdout, stderr }; + } finally { + signal?.removeEventListener('abort', onAbort); + } } export function readStream(stream: Readable): Promise { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index ad50fc27e..eafe8f435 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1,3 +1,5 @@ +import { getMaxListeners } from 'node:events'; + import { type ToolCall } from '#/kosong/contract/message'; import { emptyUsage } from '#/kosong/contract/usage'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -503,6 +505,22 @@ describe('Agent loop', () => { ); }); + it('raises the abort-listener ceiling on the step signal for parallel tool bursts', async () => { + profile.update({ activeToolNames: [] }); + let observed = 0; + loop.hooks.onDidFinishStep.register('test-step-signal-listener-ceiling', async (hookCtx, next) => { + observed = getMaxListeners(hookCtx.signal); + await next(); + }); + + ctx.mockNextResponse({ type: 'text', text: 'answer' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + await ctx.untilTurnEnd(); + + expect(observed).toBe(64); + }); + it('ends the turn when an afterStep hook sets stopTurn even though the model requested tool calls', async () => { const lookupCall: ToolCall = { type: 'function', diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts index 370ad7636..329cc934c 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts @@ -1,3 +1,4 @@ +import { listenerCount, type EventEmitter } from 'node:events'; import { Readable, Writable } from 'node:stream'; import { describe, expect, it } from 'vitest'; @@ -76,4 +77,10 @@ describe('runCommand', () => { await promise; expect(killed).toBe(true); }); + + it('removes the abort listener once the command completes', async () => { + const controller = new AbortController(); + await runCommand(fakeRunner(fakeProcess()), ['echo'], { signal: controller.signal }); + expect(listenerCount(controller.signal as unknown as EventEmitter, 'abort')).toBe(0); + }); }); From 237c7a23854c22a8400bc8c18d96b255adaaa58d Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:16:19 -0400 Subject: [PATCH 05/13] fix(agent-gateway): report run_in_background on the task wire --- .changeset/tasks-run-in-background.md | 5 +++++ packages/agent-gateway/src/protocol/task.ts | 2 +- packages/agent-gateway/src/routes/tasks.ts | 2 +- packages/agent-gateway/test/tasks.test.ts | 18 +++++++++--------- .../protocol/src/__tests__/rest-task.test.ts | 1 + packages/protocol/src/__tests__/task.test.ts | 7 +++++++ packages/protocol/src/task.ts | 4 ++++ 7 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 .changeset/tasks-run-in-background.md diff --git a/.changeset/tasks-run-in-background.md b/.changeset/tasks-run-in-background.md new file mode 100644 index 000000000..2b466274c --- /dev/null +++ b/.changeset/tasks-run-in-background.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix foreground subagents being reported as background tasks on the task list. diff --git a/packages/agent-gateway/src/protocol/task.ts b/packages/agent-gateway/src/protocol/task.ts index 1f0309530..1915eaf43 100644 --- a/packages/agent-gateway/src/protocol/task.ts +++ b/packages/agent-gateway/src/protocol/task.ts @@ -30,6 +30,6 @@ export const taskSchema = z.object({ agent_id: z.string().optional(), subagent_type: z.string().optional(), parent_tool_call_id: z.string().optional(), - run_in_background: z.boolean().optional(), + run_in_background: z.boolean(), }); export type Task = z.infer; diff --git a/packages/agent-gateway/src/routes/tasks.ts b/packages/agent-gateway/src/routes/tasks.ts index 834135e5c..cbe4fec91 100644 --- a/packages/agent-gateway/src/routes/tasks.ts +++ b/packages/agent-gateway/src/routes/tasks.ts @@ -272,7 +272,7 @@ function toWireTask( status, created_at: createdIso, started_at: createdIso, - run_in_background: info.detached !== false, + run_in_background: info.detached ?? true, }; if (info.endedAt !== null && info.endedAt !== undefined) { base.completed_at = new Date(info.endedAt).toISOString(); diff --git a/packages/agent-gateway/test/tasks.test.ts b/packages/agent-gateway/test/tasks.test.ts index b53424d6b..214360cbe 100644 --- a/packages/agent-gateway/test/tasks.test.ts +++ b/packages/agent-gateway/test/tasks.test.ts @@ -235,21 +235,21 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { expect(byId.get(questionId)?.parent_tool_call_id).toBeUndefined(); }); - it('reports run_in_background false for a foreground (non-detached) task', async () => { + it('reports run_in_background from the task detached flag', async () => { const id = await createSession(); const tasks = await mainAgentTasks(id); + const backgroundId = tasks.registerTask(fakeTask('agent')); const foregroundId = tasks.registerTask(fakeTask('agent'), { detached: false }); await flush(); - const listed = await getJson(`/api/v1/sessions/${id}/tasks`); - expect(listed.body.code).toBe(0); - const entry = listed.body.data.items.find((t) => t.id === foregroundId); - expect(entry).toMatchObject({ kind: 'subagent', status: 'running' }); - expect(entry?.run_in_background).toBe(false); + const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); + expect(body.code).toBe(0); + const byId = new Map(body.data.items.map((t) => [t.id, t])); + expect(byId.get(backgroundId)?.run_in_background).toBe(true); + expect(byId.get(foregroundId)?.run_in_background).toBe(false); - const got = await getJson(`/api/v1/sessions/${id}/tasks/${foregroundId}`); - expect(got.body.code).toBe(0); - expect(got.body.data.run_in_background).toBe(false); + const single = await getJson(`/api/v1/sessions/${id}/tasks/${foregroundId}`); + expect(single.body.data.run_in_background).toBe(false); }); it('filters the list by wire status', async () => { diff --git a/packages/protocol/src/__tests__/rest-task.test.ts b/packages/protocol/src/__tests__/rest-task.test.ts index 3fef8945f..f0dfeed72 100644 --- a/packages/protocol/src/__tests__/rest-task.test.ts +++ b/packages/protocol/src/__tests__/rest-task.test.ts @@ -49,6 +49,7 @@ describe('getTaskResponseSchema', () => { description: 'spin up x', status: 'running' as const, created_at: '2026-06-04T10:00:00.000Z', + run_in_background: true, }; expect(getTaskResponseSchema.parse(t).kind).toBe('subagent'); }); diff --git a/packages/protocol/src/__tests__/task.test.ts b/packages/protocol/src/__tests__/task.test.ts index 4bcffbdf0..f9bcb09eb 100644 --- a/packages/protocol/src/__tests__/task.test.ts +++ b/packages/protocol/src/__tests__/task.test.ts @@ -43,12 +43,19 @@ describe('taskSchema', () => { status: 'running', created_at: '2026-06-04T10:00:00.000Z', started_at: '2026-06-04T10:00:00.000Z', + run_in_background: true, }; it('round-trips a running task', () => { expect(taskSchema.parse(full)).toEqual(full); }); + it('accepts run_in_background when present and omits it freely (optional)', () => { + const { run_in_background: _omitted, ...withoutFlag } = full; + expect(taskSchema.safeParse(withoutFlag).success).toBe(true); + expect(taskSchema.safeParse({ ...full, run_in_background: false }).success).toBe(true); + }); + it('round-trips a completed task with completed_at + output fields', () => { const completed: Task = { ...full, diff --git a/packages/protocol/src/task.ts b/packages/protocol/src/task.ts index a96b3a8a1..159d7a42e 100644 --- a/packages/protocol/src/task.ts +++ b/packages/protocol/src/task.ts @@ -31,6 +31,10 @@ export const taskSchema = z.object({ /** Subagent tasks only: the child's effective thinking effort at spawn. */ thinking_effort: z.string().optional(), agent_id: z.string().optional(), + /** Whether the task runs detached from the caller's turn (background). + * Optional: producers that predate the field (e.g. the agent-core v1 + * task service) omit it — consumers apply the foreground fallback. */ + run_in_background: z.boolean().optional(), }); export type Task = z.infer; From c8efdac3d01e57326e750523ab68948d6a6b04be Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 12:21:26 -0400 Subject: [PATCH 06/13] fix: persist a picked thinking effort up to the model's default effort A concrete effort picked in the TUI or the VS Code webview is stored as the global default only when it does not rank above the model's effective default; a more expensive pick stays session-only. --- .changeset/broadcast-user-prompts.md | 5 + .changeset/drop-allow-remote-terminals.md | 5 + .changeset/effort-persist-default-ceiling.md | 5 + .changeset/infinite-retry-mode.md | 5 + .../vscode-effort-persist-default-ceiling.md | 5 + apps/pythinker-code/src/cli/sub/web/run.ts | 6 - apps/pythinker-code/src/cli/sub/web/shared.ts | 5 - .../pythinker-code/src/tui/commands/config.ts | 2 +- .../src/tui/commands/provider.ts | 19 ++- .../src/tui/controllers/editor-keyboard.ts | 2 +- .../src/tui/utils/thinking-config.ts | 33 +++-- apps/pythinker-code/test/cli/web/web.test.ts | 2 +- .../test/tui/commands/provider.test.ts | 93 +++++++++++++ .../tui/pythinker-tui-message-flow.test.ts | 108 +++++++++++++++ .../test/tui/utils/thinking-config.test.ts | 73 ++++++++++- apps/vscode/src/handlers/config.handler.ts | 48 +++++-- apps/vscode/test/bridge-handler.test.ts | 68 +++++++++- apps/vscode/test/settings-store.test.ts | 124 +++++++++++++++++- .../webview-ui/src/stores/settings.store.ts | 47 ++++++- .../src/agent/prompt/promptService.ts | 50 +++++-- .../test/agent/loop/loop.test.ts | 6 + .../test/agent/prompt/promptService.test.ts | 28 +++- .../test/app/config/config.test.ts | 4 + .../test/features/plan/plan.test.ts | 4 + .../agent-core-v2/test/harness/snapshots.ts | 2 +- packages/agent-core-v2/test/tool/tool.test.ts | 4 + .../src/services/transcript/coreEventMap.ts | 48 ++++--- packages/agent-gateway/src/start.ts | 3 +- .../ws/v1/sessionEventBroadcaster.ts | 3 +- .../test/securityExposure.test.ts | 20 --- .../test/services/transcript.test.ts | 8 ++ .../test/sessionEventBroadcaster.test.ts | 6 +- .../agent-gateway/test/transcript.test.ts | 57 ++++++++ packages/node-sdk/src/v2/event-mapper.ts | 1 + 34 files changed, 794 insertions(+), 105 deletions(-) create mode 100644 .changeset/broadcast-user-prompts.md create mode 100644 .changeset/drop-allow-remote-terminals.md create mode 100644 .changeset/effort-persist-default-ceiling.md create mode 100644 .changeset/infinite-retry-mode.md create mode 100644 .changeset/vscode-effort-persist-default-ceiling.md create mode 100644 apps/pythinker-code/test/tui/commands/provider.test.ts diff --git a/.changeset/broadcast-user-prompts.md b/.changeset/broadcast-user-prompts.md new file mode 100644 index 000000000..982b2f21b --- /dev/null +++ b/.changeset/broadcast-user-prompts.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix messages sent from one web client not appearing on other clients connected to the same session. diff --git a/.changeset/drop-allow-remote-terminals.md b/.changeset/drop-allow-remote-terminals.md new file mode 100644 index 000000000..dd5ce0ac1 --- /dev/null +++ b/.changeset/drop-allow-remote-terminals.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remove the `--allow-remote-terminals` flag from `pythinker web`; PTY terminal routes now stay available on loopback binds only. diff --git a/.changeset/effort-persist-default-ceiling.md b/.changeset/effort-persist-default-ceiling.md new file mode 100644 index 000000000..1631bab64 --- /dev/null +++ b/.changeset/effort-persist-default-ceiling.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Persist a picked thinking effort as the default only up to the model's own default effort; a more expensive pick stays session-only. diff --git a/.changeset/infinite-retry-mode.md b/.changeset/infinite-retry-mode.md new file mode 100644 index 000000000..91a9684d4 --- /dev/null +++ b/.changeset/infinite-retry-mode.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add `PYTHINKER_CODE_INFINITE_RETRY=1` to retry every failed model request indefinitely with backoff instead of failing the turn, for long unattended runs. diff --git a/.changeset/vscode-effort-persist-default-ceiling.md b/.changeset/vscode-effort-persist-default-ceiling.md new file mode 100644 index 000000000..997c5d402 --- /dev/null +++ b/.changeset/vscode-effort-persist-default-ceiling.md @@ -0,0 +1,5 @@ +--- +"pythinker": patch +--- + +Persist a picked thinking effort as the default only up to the model's own default effort; a more expensive pick stays session-only. diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 207fc0872..cb150f5a6 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -134,11 +134,6 @@ export function buildWebCommand(cmd: Command): Command { 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', false, ) - .option( - '--allow-remote-terminals', - 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', - false, - ) .option( '--dangerous-bypass-auth', 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', @@ -300,7 +295,6 @@ async function runServerInProcess( debugEndpoints: options.debugEndpoints, insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, - allowRemoteTerminals: options.allowRemoteTerminals, allowedHosts: options.allowedHosts, disableAuth: options.dangerousBypassAuth, webTitle: options.webTitle, diff --git a/apps/pythinker-code/src/cli/sub/web/shared.ts b/apps/pythinker-code/src/cli/sub/web/shared.ts index b574af797..e0265ad98 100644 --- a/apps/pythinker-code/src/cli/sub/web/shared.ts +++ b/apps/pythinker-code/src/cli/sub/web/shared.ts @@ -40,8 +40,6 @@ export interface ParsedServerOptions { insecureNoTls: boolean; /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ allowRemoteShutdown: boolean; - /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ - allowRemoteTerminals: boolean; /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ dangerousBypassAuth: boolean; /** Extra `Host` header values to allow through the DNS-rebinding check. */ @@ -59,8 +57,6 @@ export interface ServerCliOptions { insecureNoTls?: boolean; /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ allowRemoteShutdown?: boolean; - /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ - allowRemoteTerminals?: boolean; /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ dangerousBypassAuth?: boolean; /** Extra `Host` header values to allow (`--allowed-host`). */ @@ -77,7 +73,6 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions debugEndpoints: opts.debugEndpoints === true, insecureNoTls: opts.insecureNoTls !== false, allowRemoteShutdown: opts.allowRemoteShutdown === true, - allowRemoteTerminals: opts.allowRemoteTerminals === true, dangerousBypassAuth: opts.dangerousBypassAuth === true, allowedHosts: parseAllowedHostArgs(opts.allowedHost), webTitle: opts.webTitle, diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index 69f9e1bfb..d9276903c 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -597,7 +597,7 @@ async function persistModelSelection( const model = host.state.appState.availableModels[alias]; const full = thinkingEffortToConfig( effort, - model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + model === undefined ? undefined : effectiveModelForHost(host, model), ); // Re-confirming the effort shown when the picker opened is not an explicit // choice — persist the model but leave the stored effort preference alone. diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index 998e204db..beae27765 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -295,7 +295,7 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { host.mountEditorReplacement(selector); } -async function setDefaultModel( +export async function setDefaultModel( host: SlashCommandHost, alias: string, effort: ThinkingEffort, @@ -303,16 +303,23 @@ async function setDefaultModel( // Resolve efforts the same way the /model path does (effectiveModelForHost // applies overrides and the protocol-profile inference): catalog entries for // e.g. Anthropic models declare no support_efforts on the alias, and without - // the inference a top-tier pick would slip through as a persisted effort. + // the inference an above-default pick would slip through as a persisted effort. const model = host.state.appState.availableModels[alias]; + const thinking = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model), + ); await host.harness.setConfig({ defaultModel: alias, - thinking: thinkingEffortToConfig( - effort, - model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, - ), + thinking, }); await host.authFlow.refreshConfigAfterLogin(); + // refreshConfigAfterLogin reactivates from the persisted config, so a pick + // the gate keeps session-only never reaches the runtime — apply it after + // the refresh, or the persisted value would clobber it. + if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') { + await host.authFlow.activateModelAfterLogin(alias, effort); + } host.track('model_switch', { model: alias }); host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index f909dffc4..0e7b600fc 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -595,7 +595,7 @@ export class EditorKeyboardController { const harness = this.host.harness; if (harness === undefined || alias !== this.host.state.appState.model) return; try { - await harness.setConfig({ thinking: thinkingEffortToConfig(effort, model.supportEfforts) }); + await harness.setConfig({ thinking: thinkingEffortToConfig(effort, model) }); } catch (error) { this.host.showError( `Thinking effort set to ${effort}, but failed to save default: ${formatErrorMessage(error)}`, diff --git a/apps/pythinker-code/src/tui/utils/thinking-config.ts b/apps/pythinker-code/src/tui/utils/thinking-config.ts index 22eb020ee..4dd23e69c 100644 --- a/apps/pythinker-code/src/tui/utils/thinking-config.ts +++ b/apps/pythinker-code/src/tui/utils/thinking-config.ts @@ -1,4 +1,4 @@ -import type { ThinkingEffort } from '@pymodel/pythinker-code-sdk'; +import type { ModelAlias, ThinkingEffort } from '@pymodel/pythinker-code-sdk'; /** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ export function isThinkingOn(effort: ThinkingEffort): boolean { @@ -11,24 +11,37 @@ export function isThinkingOn(effort: ThinkingEffort): boolean { * on-signal rather than a declared effort, so it only persists `enabled` — * boolean models resolve back to `'on'` at runtime via * `defaultThinkingEffortFor`. A concrete effort persists as the global - * default, EXCEPT the model's highest declared level — the last entry of - * `support_efforts` (the list is ordered by strength, the same assumption - * the `middleOf` default-effort resolution makes) — which is session-only - * and records just `enabled`, so the most expensive tier never becomes the - * global default for every new session. When the model's levels are unknown - * the concrete effort is persisted as-is. + * default, EXCEPT when it ranks above the model's effective default + * effort: `support_efforts` is ordered by strength (the same assumption + * the `middleOf` default-effort resolution makes), and a pick more + * expensive than the default stays session-only and records just + * `enabled`, so it never becomes the global default for every new + * session. The default here is the effective model's, however it arose — + * declared via the catalog or `[models.*.overrides]`, or synthesized by + * the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to 'high', so an 'xhigh' pick stays session-only there). When + * the effective model carries no default effort at all, its highest + * declared level stays session-only (the historical rule). Undeclared + * values persist as-is — the configured provider validates them. */ export function thinkingEffortToConfig( effort: ThinkingEffort, - supportEfforts?: readonly string[], + model?: Pick, ): { enabled: boolean; effort?: string; } { if (effort === 'off') return { enabled: false }; if (effort === 'on') return { enabled: true }; - const top = supportEfforts?.at(-1); - if (top !== undefined && effort === top) return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } return { enabled: true, effort }; } diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 10876f799..2b6f5f9de 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -99,7 +99,6 @@ describe('pythinker web', () => { expect(longs).toContain('--allowed-host'); expect(longs).toContain('--insecure-no-tls'); expect(longs).toContain('--allow-remote-shutdown'); - expect(longs).toContain('--allow-remote-terminals'); expect(longs).toContain('--dangerous-bypass-auth'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); @@ -112,6 +111,7 @@ describe('pythinker web', () => { expect(longs).not.toContain('--keep-alive'); expect(longs).not.toContain('--daemon'); expect(longs).not.toContain('--idle-grace-ms'); + expect(longs).not.toContain('--allow-remote-terminals'); }); it('routes `pythinker server` and any legacy subcommand to a deprecation notice', async () => { diff --git a/apps/pythinker-code/test/tui/commands/provider.test.ts b/apps/pythinker-code/test/tui/commands/provider.test.ts new file mode 100644 index 000000000..81117cc5b --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/provider.test.ts @@ -0,0 +1,93 @@ +/** + * Scenario: /provider post-add default-model selection. + * Responsibilities: the picked effort is gated for persistence by the model's + * effective default, and a session-only pick is still applied to the runtime + * after the config refresh (which only reactivates from persisted values). + * Wiring: real setDefaultModel with the harness/authFlow boundaries stubbed by + * a small host rig. + * Run: pnpm -C apps/pythinker-code exec vitest run test/tui/commands/provider.test.ts + */ +import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { setDefaultModel } from '#/tui/commands/provider'; + +function makeHost() { + const appState = { + availableModels: { + // Declares no efforts; the Anthropic profile inference supplies + // [low, medium, high, xhigh, max] with the default resolved to 'high'. + opus: { + provider: 'compatible', + model: 'claude-opus-4-7', + maxContextSize: 200_000, + } as unknown as ModelAlias, + }, + availableProviders: { + compatible: { type: 'anthropic' }, + }, + }; + const host = { + state: { appState }, + harness: { + setConfig: vi.fn(async () => ({})), + }, + authFlow: { + refreshConfigAfterLogin: vi.fn(async () => {}), + activateModelAfterLogin: vi.fn(async () => {}), + }, + track: vi.fn(), + showStatus: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { setConfig: ReturnType }; + authFlow: { + refreshConfigAfterLogin: ReturnType; + activateModelAfterLogin: ReturnType; + }; + }; + return { host }; +} + +describe('setDefaultModel', () => { + it('applies an above-default pick to the runtime when the gate keeps it session-only', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'xhigh'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh'); + // The application must come after the refresh, or the persisted value + // reactivated by refreshConfigAfterLogin would clobber the pick. + expect( + host.authFlow.activateModelAfterLogin.mock.invocationCallOrder[0]!, + ).toBeGreaterThan(host.authFlow.refreshConfigAfterLogin.mock.invocationCallOrder[0]!); + }); + + it('does not re-apply the effort when the pick persists', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'high'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true, effort: 'high' }, + }); + expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled(); + }); + + it('does not re-apply a boolean on pick', async () => { + const { host } = makeHost(); + + await setDefaultModel(host, 'opus', 'on'); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index c38656ed8..81128552c 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -7864,6 +7864,114 @@ describe('/effort support_efforts override', () => { }); expect(session.setThinking).not.toHaveBeenCalled(); }); + + it('persists max when the model default effort is max', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: switched ? 'max' : 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setThinking: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'pythinker', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }, + }, + defaultModel: 'k2', + // A previously stored effort keeps the runtime below the delivered + // max default, so picking max is an explicit change. + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'k2', + thinking: { enabled: true, effort: 'max' }, + }); + }); + expect(driver.state.appState.thinkingEffort).toBe('max'); + }); + + it('keeps an xhigh pick session-only for a Claude model via the profile inference', async () => { + // claude-opus-4-7 declares no efforts; the Anthropic profile inference + // supplies [low, medium, high, xhigh, max] and resolves the default to + // 'high', so an xhigh pick ranks above the persistence ceiling. + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'opus', + thinkingEffort: switched ? 'xhigh' : 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setThinking: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + opus: { + provider: 'compatible', + model: 'claude-opus-4-7', + maxContextSize: 100, + }, + }, + defaultModel: 'opus', + thinking: { enabled: true, effort: 'high' }, + })), + setConfig, + }); + + driver.handleUserInput('/effort xhigh'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('xhigh'); + }); + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'opus', + thinking: { enabled: true }, + }); + }); + expect(driver.state.appState.thinkingEffort).toBe('xhigh'); + }); + }); describe('transcript step and assistant folding', () => { diff --git a/apps/pythinker-code/test/tui/utils/thinking-config.test.ts b/apps/pythinker-code/test/tui/utils/thinking-config.test.ts index e0a953595..fd41b7668 100644 --- a/apps/pythinker-code/test/tui/utils/thinking-config.test.ts +++ b/apps/pythinker-code/test/tui/utils/thinking-config.test.ts @@ -21,20 +21,85 @@ describe('thinkingEffortToConfig', () => { }); it.each([ - // The model's highest declared level (last support_efforts entry) is + // With no declared default effort, the historical rule applies: the + // model's highest declared level (last support_efforts entry) is // session-only; anything below it persists as the global default. ['low', { enabled: true, effort: 'low' }], ['high', { enabled: true, effort: 'high' }], ['max', { enabled: true }], // Undeclared values persist as-is (the provider validates them). ['ultra', { enabled: true, effort: 'ultra' }], - ] as const)('maps %s → %o for [low, high, max]', (effort, expected) => { - expect(thinkingEffortToConfig(effort, ['low', 'high', 'max'])).toEqual(expected); + ] as const)('maps %s → %o for [low, high, max] without a default', (effort, expected) => { + expect(thinkingEffortToConfig(effort, { supportEfforts: ['low', 'high', 'max'] })).toEqual( + expected, + ); }); it('treats a single declared level as the top tier', () => { - expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true }); + expect(thinkingEffortToConfig('max', { supportEfforts: ['max'] })).toEqual({ enabled: true }); }); + + it.each([ + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + // Above the delivered default: session-only. + ['max', { enabled: true }], + ] as const)('maps %s → %o for [low, high, max] with default high', (effort, expected) => { + expect( + thinkingEffortToConfig(effort, { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }), + ).toEqual(expected); + }); + + it('persists the top tier when the delivered default is the top tier', () => { + expect( + thinkingEffortToConfig('max', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }), + ).toEqual({ enabled: true, effort: 'max' }); + }); + + it('keeps a non-top pick above the delivered default session-only', () => { + expect( + thinkingEffortToConfig('high', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'low', + }), + ).toEqual({ enabled: true }); + }); + + it('falls back to the top-tier rule when the declared default is not a listed level', () => { + expect( + thinkingEffortToConfig('max', { + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'ultra', + }), + ).toEqual({ enabled: true }); + }); + + it.each([ + ['low', { enabled: true, effort: 'low' }], + ['medium', { enabled: true, effort: 'medium' }], + ['high', { enabled: true, effort: 'high' }], + // Above the effective default: session-only. + ['xhigh', { enabled: true }], + ['max', { enabled: true }], + ] as const)( + // The shape the Anthropic profile inference hands the gate for the + // latest Claude models: five tiers with the default resolved to 'high'. + 'maps %s → %o for [low, medium, high, xhigh, max] with default high', + (effort, expected) => { + expect( + thinkingEffortToConfig(effort, { + supportEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultEffort: 'high', + }), + ).toEqual(expected); + }, + ); }); describe('isThinkingOn', () => { diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts index 104ac2f3d..28ca4bc5d 100644 --- a/apps/vscode/src/handlers/config.handler.ts +++ b/apps/vscode/src/handlers/config.handler.ts @@ -4,6 +4,7 @@ import { buildSkillSlashCommands, effectiveModelAlias, type ModelAlias, + type ProviderType, type PythinkerConfig as SdkPythinkerConfig, type SkillSlashCommand, type ThinkingEffort, @@ -49,9 +50,14 @@ const saveConfig: Handler = async (params, ctx) const effortChanged = params.effortChanged !== false; const config = await ctx.harness.getConfig({ reload: true }); const model = config.models?.[params.model]; + // Resolve with the provider type the way the TUI's effectiveModelForHost + // does: without it the Anthropic fallback profile (e.g. `claude-latest`) + // never matches, so the inferred default that gates persistence is missed. + const providerType = + model === undefined ? undefined : (config.providers?.[model.provider]?.type ?? model.protocol); const full = thinkingConfig( effort, - model === undefined ? undefined : effectiveModelAlias(model).supportEfforts, + model === undefined ? undefined : effectiveModelAlias(model, providerType), ); // Re-confirming the effort already shown is not an explicit choice — // persist the model but leave the stored effort preference alone (the TUI's @@ -167,7 +173,12 @@ export const configHandlers = { export function toWebviewConfig(config: SdkPythinkerConfig): ModelsConfig { const models: ModelConfig[] = Object.entries(config.models ?? {}) - .map(([id, model]) => toWebviewModel(id, model)) + // Resolve with the provider type the way saveConfig does: without it the + // Anthropic fallback profile never matches, and the webview's effort + // persistence seed would gate on a different effective model. + .map(([id, model]) => + toWebviewModel(id, model, config.providers?.[model.provider]?.type ?? model.protocol), + ) .toSorted((left, right) => left.name.localeCompare(right.name)); return { defaultModel: config.defaultModel ?? models[0]?.id ?? null, @@ -177,8 +188,8 @@ export function toWebviewConfig(config: SdkPythinkerConfig): ModelsConfig { }; } -function toWebviewModel(id: string, model: ModelAlias): ModelConfig { - const effective = effectiveModelAlias(model); +function toWebviewModel(id: string, model: ModelAlias, providerType?: ProviderType): ModelConfig { + const effective = effectiveModelAlias(model, providerType); return { id, name: effective.displayName ?? effective.model ?? id, @@ -196,19 +207,32 @@ function toWebviewModel(id: string, model: ModelAlias): ModelConfig { * Project a thinking effort to the `[thinking]` config patch persisted to * config.toml — mirrors the TUI's thinkingEffortToConfig. "off" disables * thinking; "on" is the boolean-model on-signal, so it only persists - * `enabled`. A concrete effort persists as the global default, EXCEPT the - * model's highest declared level — the last entry of `support_efforts` — - * which is session-only and records just `enabled`, so the most expensive - * tier never becomes the global default for every new session. When the - * model's levels are unknown the concrete effort is persisted as-is. + * `enabled`. A concrete effort persists as the global default, EXCEPT when it + * ranks above the model's effective default effort: `support_efforts` is + * ordered by strength, and a pick more expensive than the default stays + * session-only and records just `enabled`, so it never becomes the global + * default for every new session. The default here is the effective model's, + * however it arose — declared via the catalog or overrides, or synthesized + * by the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to "high", so an "xhigh" pick stays session-only there). When the + * effective model carries no default effort at all, its highest declared + * level stays session-only (the historical rule). When the model's levels + * are unknown the concrete effort is persisted as-is. */ function thinkingConfig( effort: ThinkingEffort, - supportEfforts?: readonly string[], + model?: Pick, ): { enabled: boolean; effort?: string } { if (effort === "off") return { enabled: false }; if (effort === "on") return { enabled: true }; - const top = supportEfforts?.at(-1); - if (top !== undefined && effort === top) return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } return { enabled: true, effort }; } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index cff8420b2..8cdfa811c 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -428,6 +428,36 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => }); }); + it("resolves the fallback-profile default effort with the provider type", async () => { + // claude-latest declares efforts but no default; the Anthropic fallback + // profile only matches when the provider type joins the resolution. + host.harness.getConfig.mockResolvedValueOnce({ + defaultModel: "custom/claude", + providers: { + custom: { type: "anthropic", apiKey: "test-key" }, + }, + models: { + "custom/claude": { + provider: "custom", + model: "claude-latest", + supportEfforts: ["low", "medium", "high", "xhigh", "max"], + }, + }, + }); + + const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1"); + + expect(result).toMatchObject({ + result: { + models: [{ + id: "custom/claude", + support_efforts: ["low", "medium", "high", "xhigh", "max"], + default_effort: "high", + }], + }, + }); + }); + it("does not expose the session storage path when listing sessions", async () => { host.harness.listSessions.mockResolvedValueOnce([ { @@ -648,7 +678,7 @@ describe("Webview config saves (thinking effort persistence parity with the TUI) }); }); - it("keeps the model's top declared tier session-only", async () => { + it("keeps a pick above the model's delivered default session-only", async () => { mockConfig({ enabled: false }); await bridge.handle( @@ -662,6 +692,42 @@ describe("Webview config saves (thinking effort persistence parity with the TUI) }); }); + it("persists the top tier when the model's delivered default is the top tier", async () => { + host.harness.getConfig.mockResolvedValue({ + defaultModel: "pythinker/reasoning", + models: { "pythinker/reasoning": { ...effortModel, defaultEffort: "max" } }, + } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "pythinker/reasoning", thinking: true, effort: "max" } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "pythinker/reasoning", + thinking: { enabled: true, effort: "max" }, + }); + }); + + it("keeps an xhigh pick session-only when the default comes from the Anthropic profile inference", async () => { + // claude-opus-4-7 declares no efforts; the profile inference supplies + // [low, medium, high, xhigh, max] and resolves the default to "high". + host.harness.getConfig.mockResolvedValue({ + defaultModel: "custom/claude", + models: { "custom/claude": { provider: "custom", model: "claude-opus-4-7" } }, + } as never); + + await bridge.handle( + { id: "rpc-1", method: Methods.SaveConfig, params: { model: "custom/claude", thinking: true, effort: "xhigh" } }, + "view-1", + ); + + expect(host.harness.setConfig).toHaveBeenCalledWith({ + defaultModel: "custom/claude", + thinking: { enabled: true }, + }); + }); + it("persists the concrete effort when the model's levels are unknown", async () => { host.harness.getConfig.mockResolvedValue({ defaultModel: "other/model", models: {} }); diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index ff43498b7..386d42909 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -452,7 +452,7 @@ describe("Webview thinking effort parity with the TUI", () => { expect(boundary.saveConfig).not.toHaveBeenCalled(); }); - it("does not seed future sessions with the model's top declared tier", () => { + it("seeds the top tier when it is the model's delivered default", () => { boundary.saveConfig.mockResolvedValue({ ok: true }); useSettingsStore.getState().initModels(MODELS, "reasoning", false); @@ -460,6 +460,128 @@ describe("Webview thinking effort parity with the TUI", () => { expect(useSettingsStore.getState().thinkingEffort).toBe("high"); expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("high"); + }); + + it("does not seed a pick above the model's delivered default", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { + id: "reasoning", + name: "Reasoning", + provider: "acme", + capabilities: ["thinking"], + support_efforts: ["low", "high", "max"], + default_effort: "low", + }, + ], "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("high"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); + }); + + it("does not seed the top tier when the model declares no default", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { + id: "reasoning", + name: "Reasoning", + provider: "acme", + capabilities: ["thinking"], + support_efforts: ["low", "high"], + }, + ], "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("high"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); + }); + + const SWITCH_MODELS = [ + { + id: "seeded", + name: "Seeded", + provider: "acme", + capabilities: ["thinking"], + support_efforts: ["low", "medium"], + default_effort: "medium", + }, + { + id: "max-default", + name: "Max Default", + provider: "acme", + capabilities: ["thinking"], + support_efforts: ["low", "max"], + default_effort: "max", + }, + ]; + + it("updates the seed when a model switch persists the derived effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); + + // "medium" is unsupported here, so the switch derives the model default + // "max"; with the delivered default at the top tier the host persists it. + useSettingsStore.getState().updateModel("max-default"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("max"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ + model: "max-default", + thinking: true, + effort: "max", + effortChanged: true, + }); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); + }); + + it("rolls the seed back when the model-switch save fails", async () => { + let rejectSave!: (error: Error) => void; + boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => { + rejectSave = reject; + })); + useSettingsStore.getState().initModels(SWITCH_MODELS, "seeded", true, "medium"); + + useSettingsStore.getState().updateModel("max-default"); + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("max"); + + rejectSave(new Error("config.toml is read-only")); + await vi.waitFor(() => { + expect(useSettingsStore.getState().defaultThinkingEffort).toBe("medium"); + }); + }); + + it("leaves the seed alone when the switch re-confirms the active effort", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + // No persisted effort: the seed starts undefined and the session derives + // "max" from the model default. + useSettingsStore.getState().initModels([ + ...SWITCH_MODELS, + { + id: "max-default-b", + name: "Max Default B", + provider: "acme", + capabilities: ["thinking"], + support_efforts: ["low", "max"], + default_effort: "max", + }, + ], "max-default", true); + + // The derived effort equals the active one, so the host leaves the stored + // preference untouched — the seed must not invent one either. + useSettingsStore.getState().updateModel("max-default-b"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("max"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ + model: "max-default-b", + thinking: true, + effort: "max", + effortChanged: false, + }); expect(useSettingsStore.getState().defaultThinkingEffort).toBeUndefined(); }); diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 1deeaac2a..f0e3824cb 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -88,6 +88,23 @@ function defaultEffortForModel(model: ModelConfig, defaultThinking: boolean, con return defaultThinking ? "on" : "off"; } +/** + * Whether picking `effort` persists it as the global default — mirrors the + * extension host's thinkingConfig gate: a pick above the model's effective + * default effort stays session-only, with the ceiling falling back to the + * tier below the top when the model carries no listed default. Only listed + * efforts reach this helper (selectThinkingEffort rejects the rest). + */ +function persistsAsDefaultEffort(model: ModelConfig, effort: string): boolean { + const efforts = model.support_efforts ?? []; + const declared = model.default_effort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + return efforts.indexOf(effort) <= ceiling; +} + export function isImageModel(model: ModelConfig): boolean { return model.capabilities.includes("image_in"); } @@ -193,15 +210,29 @@ export const useSettingsStore = create((set, get) => ({ } const thinkingEffort = defaultEffortForModel(model, defaultThinking, defaultThinkingEffort); - set({ currentModel: modelId, thinkingEffort }); + const effortChanged = thinkingEffort !== previousEffort; + set({ + currentModel: modelId, + thinkingEffort, + // The save below persists the derived effort when it changed and + // clears the gate — keep the seed in sync, or the next switch derives + // from a stale value and saves it back over the persisted one. + defaultThinkingEffort: + effortChanged && + thinkingEffort !== "off" && + thinkingEffort !== "on" && + persistsAsDefaultEffort(model, thinkingEffort) + ? thinkingEffort + : defaultThinkingEffort, + }); saveConfigWithRollback( { model: modelId, thinking: thinkingEffort !== "off", effort: thinkingEffort, - effortChanged: thinkingEffort !== previousEffort, + effortChanged, }, - { currentModel, thinkingEffort: previousEffort }, + { currentModel, thinkingEffort: previousEffort, defaultThinkingEffort }, set, ); }, @@ -248,11 +279,13 @@ export const useSettingsStore = create((set, get) => ({ set({ thinkingEffort, defaultThinking: thinkingEffort !== "off", - // The model's top declared tier is session-only (only the boolean - // toggle is persisted), so it must not become the configured-effort - // seed for future sessions. + // A pick above the model's effective default effort is session-only + // (only the boolean toggle is persisted), so it must not become the + // configured-effort seed for future sessions. defaultThinkingEffort: - thinkingEffort !== "off" && thinkingEffort !== "on" && thinkingEffort !== allowed.at(-1) + thinkingEffort !== "off" && + thinkingEffort !== "on" && + persistsAsDefaultEffort(model, thinkingEffort) ? thinkingEffort : defaultThinkingEffort, }); diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index f4e396bc9..2be090b06 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -106,6 +106,32 @@ export class PromptQueued extends AgentEvent2 { } export interface PromptQueued extends PromptQueuedPayload {} +export interface PromptSubmittedPayload { + readonly agentId: string; + readonly promptId: string; + readonly userMessageId: string; + readonly status: 'running' | 'queued'; + readonly content: ContentPart[]; + readonly createdAt: string; +} + +export class PromptSubmitted extends AgentEvent2 { + static override readonly type = 'prompt.submitted'; + static override readonly observable = true; +} +export interface PromptSubmitted extends PromptSubmittedPayload {} + +export interface PromptStartedPayload { + readonly agentId: string; + readonly promptId: string; +} + +export class PromptStarted extends AgentEvent2 { + static override readonly type = 'prompt.started'; + static override readonly observable = true; +} +export interface PromptStarted extends PromptStartedPayload {} + interface Deferred { readonly promise: Promise; resolve(value: T): void; reject(reason: unknown): void } interface Record extends PromptSnapshot { state: PromptState; @@ -236,16 +262,15 @@ export class AgentPromptService implements IAgentPromptService { completion: completionDeferred.promise, }; this.pending.push(record); - if (this.active === undefined && !this.launching) { - if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { - this.publishQueued(record); - return record.handle; - } - void this.startNext(); - await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); - } else { + const idle = this.active === undefined && !this.launching; + const queued = !idle || (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running'); + this.publishSubmitted(record, queued ? 'queued' : 'running'); + if (queued) { this.publishQueued(record); + return record.handle; } + void this.startNext(); + await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); return record.handle; } @@ -421,6 +446,7 @@ export class AgentPromptService implements IAgentPromptService { const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminder())).assigned).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); + this.publishStarted(item); void turn.result.then((result) => this.settle(item, result)); } catch { item.state = 'failed'; @@ -491,6 +517,14 @@ export class AgentPromptService implements IAgentPromptService { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; void this.dispatcher.dispatch(new PromptQueued({ agentId: this.scopeContext.agentId, promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length })); } + private publishSubmitted(record: Record, status: 'running' | 'queued'): void { + if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; + void this.dispatcher.dispatch(new PromptSubmitted({ agentId: this.scopeContext.agentId, promptId: record.id, userMessageId: record.userMessageId, status, content: stripBundledSkillBlocks(record.message), createdAt: record.createdAt })); + } + private publishStarted(record: Record): void { + if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; + void this.dispatcher.dispatch(new PromptStarted({ agentId: this.scopeContext.agentId, promptId: record.id })); + } private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ agentId: this.scopeContext.agentId, promptId, abortedAt: new Date().toISOString() })); } } diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index eafe8f435..00b0e5b97 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -71,10 +71,12 @@ describe('Agent loop', () => { [wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "