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..db2291c09 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -164,7 +164,7 @@ What belongs here: - **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file. - **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration. -Base class: extend `Service` (from `#/_base/di/service`) when the unit needs capability calls on `this` — `provide` / `effect` / `on` / `get` / `ref` (e.g. contributing a record to a `collection` token). `Service` extends `Disposable`, so `_register` keeps working; constructor-time `provide` / `on` / `effect` calls are buffered and flushed by the kernel after construction, while `get` / `ref` throw inside the constructor (dependencies stay constructor parameters). Otherwise extend `Disposable` — both are full DI units; a service whose own members collide with the `Service` vocabulary (`name` / `state` / `config` / `get`) must stay on `Disposable` (leave a NOTE comment saying so). +Base class: extend `Service` (from `#/_base/di/service`) when the unit needs capability calls on `this` — `provide` / `effect` / `on` / `get` / `ref` (e.g. contributing a record to a `collection` token). `Service` extends `Disposable`, so `_register` keeps working; constructor-time `provide` / `on` / `effect` calls are buffered and flushed by the kernel after construction, while `get` / `ref` throw inside the constructor (dependencies stay constructor parameters). Otherwise extend `Disposable` — both are full DI units; a service whose own members collide with the `Service` vocabulary (`name` / `state` / `config` / `get`) must stay on `Disposable`. ## Constructor conventions @@ -296,9 +296,8 @@ 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). ## Complete minimal example diff --git a/.agents/skills/agent-core-dev/test.md b/.agents/skills/agent-core-dev/test.md index 0b0828822..0d9caa6b6 100644 --- a/.agents/skills/agent-core-dev/test.md +++ b/.agents/skills/agent-core-dev/test.md @@ -21,7 +21,7 @@ Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` par Pure functions, value objects, and services with **no** `@IService` dependencies may be constructed directly. -The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — annotate it with a comment explaining why. +The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — state the reason in the test name and local identifiers. ## Two harnesses 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/.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/.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..2ac445ee0 --- /dev/null +++ b/.changeset/drop-allow-remote-terminals.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": major +--- + +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/plugins-marketplace-async-versions.md b/.changeset/plugins-marketplace-async-versions.md new file mode 100644 index 000000000..38d17e5f8 --- /dev/null +++ b/.changeset/plugins-marketplace-async-versions.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show the /plugins marketplace catalog as soon as it loads, with latest-version lookups running in the background. 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/.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/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/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index 5a25f08cf..07b8bb064 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "3a46f9dff05369ad09252708dcc377f6495aadcb00e6bcea32130261bd380df2", + "sourceHash": "f1f4f846df4abed27745e6cf05a8cf9a6411b3e5b8e0a4cc66344de574dc5edd", "sourceFileCount": 399 } 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/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 7a71cceb5..df6a338ad 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -75,3 +75,7 @@ export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED; export { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '@pymodel/agent-core-v2/app/plugin/marketplace'; +// Bound on each background "latest release" lookup when the TUI fills in +// marketplace versions. Without it a stalled connection to github.com hangs +// the version phase for undici's default header timeout (300s). +export const MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS = 5000; 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/plugins.ts b/apps/pythinker-code/src/tui/commands/plugins.ts index 0b131cde7..2c25a8f06 100644 --- a/apps/pythinker-code/src/tui/commands/plugins.ts +++ b/apps/pythinker-code/src/tui/commands/plugins.ts @@ -35,7 +35,13 @@ import { isOfficialPluginSource, } from '../utils/plugin-source-label'; import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app'; -import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { + loadPluginMarketplace, + withBuiltInEntries, + withMarketplaceLatestVersions, + type PluginMarketplace, + type PluginMarketplaceEntry, +} from '#/utils/plugin-marketplace'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -343,18 +349,49 @@ async function loadMarketplaceCatalog( source: string | undefined, capabilities: readonly CapabilityStatus[], ): Promise { + const builtInEntries = + host.engineV2 && isDefaultMarketplaceCatalog(source) + ? capabilities.map(capabilityMarketplaceEntry) + : undefined; + let marketplace: PluginMarketplace; + let catalog: PluginMarketplace; try { - const marketplace = await loadPluginMarketplace({ + // Phase 1: render the catalog as soon as it arrives. Version lookups + // (GitHub releases/latest round trips) must not gate the first paint. + // Keep the raw parsed catalog for phase 2: injecting built-ins first + // would mask the matching catalog entries' GitHub sources behind + // `capability:` rows, making their versions unresolvable. + catalog = await loadPluginMarketplace({ workDir: host.state.appState.workDir, source, - builtInEntries: - host.engineV2 && isDefaultMarketplaceCatalog(source) - ? capabilities.map(capabilityMarketplaceEntry) - : undefined, + skipLatestVersions: true, }); + marketplace = + builtInEntries !== undefined ? withBuiltInEntries(catalog, builtInEntries) : catalog; panel.setMarketplace(marketplace.plugins, marketplace.source); + host.state.ui.requestRender(); } catch (error) { + // Any phase-1 failure (unreachable OR malformed catalog) surfaces as an + // error: the panel keeps built-in capability rows installable in the + // Official tab while the error is shown, and a broken catalog must not + // be masked as a successfully loaded, built-ins-only marketplace. panel.setMarketplaceError(formatErrorMessage(error)); + host.state.ui.requestRender(); + return; + } + try { + // Phase 2: resolve latest versions in the background (against the raw + // catalog), re-apply the built-in injection so resolved versions flow + // onto capability rows, then refresh so update badges appear. Failures + // degrade to badge-less rows and never clobber the rendered list. + const enrichedCatalog = await withMarketplaceLatestVersions(catalog); + const enriched = + builtInEntries !== undefined + ? withBuiltInEntries(enrichedCatalog, builtInEntries) + : enrichedCatalog; + panel.setMarketplace(enriched.plugins, enriched.source); + } catch (error) { + log.warn('marketplace version lookup failed', { error }); } host.state.ui.requestRender(); } 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/src/utils/plugin-marketplace.ts b/apps/pythinker-code/src/utils/plugin-marketplace.ts index 8231b7bae..85adcb3bb 100644 --- a/apps/pythinker-code/src/utils/plugin-marketplace.ts +++ b/apps/pythinker-code/src/utils/plugin-marketplace.ts @@ -16,11 +16,15 @@ import { type PluginMarketplaceEntry, } from '@pymodel/agent-core-v2/app/plugin/marketplace'; -import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app'; +import { + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, + MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS, +} from '#/constant/app'; export { computeUpdateStatus, PLUGIN_MARKETPLACE_TIERS, + withBuiltInEntries, type PluginMarketplace, type PluginMarketplaceEntry, type PluginMarketplaceTier, @@ -37,6 +41,31 @@ export interface LoadPluginMarketplaceOptions { * Undefined means no injection. */ readonly builtInEntries?: readonly PluginMarketplaceEntry[]; + /** + * Skip the per-entry "latest GitHub release" lookups so the catalog can be + * rendered as soon as it is parsed; the caller resolves versions in the + * background via {@link withMarketplaceLatestVersions} and re-renders. + */ + readonly skipLatestVersions?: boolean; +} + +/** + * Second phase of the marketplace load: fill in `version` for entries that + * need a GitHub `releases/latest` lookup. Every lookup gets a hard timeout + * (MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS) and per-entry failures degrade to + * a missing version (badge-less row), so this never throws for network + * reasons and never blocks the first paint. + */ +export async function withMarketplaceLatestVersions( + marketplace: PluginMarketplace, + fetchImpl: typeof fetch = fetch, +): Promise { + const timedFetch: typeof fetch = (input, init) => + fetchImpl(input, { + ...init, + signal: AbortSignal.timeout(MARKETPLACE_VERSION_LOOKUP_TIMEOUT_MS), + }); + return withLatestVersions(marketplace, timedFetch); } export async function loadPluginMarketplace( @@ -63,9 +92,8 @@ export async function loadPluginMarketplace( } throw error; } - const marketplace = await withLatestVersions( - parsePluginMarketplace(read.raw, read.location), - fetchImpl, - ); + const marketplace = options.skipLatestVersions === true + ? parsePluginMarketplace(read.raw, read.location) + : await withLatestVersions(parsePluginMarketplace(read.raw, read.location), fetchImpl); return withBuiltInEntries(marketplace, builtInEntries); } diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 10876f799..6bfa88212 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 () => { @@ -409,7 +409,6 @@ describe('`pythinker web` option threading', () => { dangerousBypassAuth: true, debugEndpoints: true, allowRemoteShutdown: true, - allowRemoteTerminals: true, open: false, }, { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, @@ -422,7 +421,6 @@ describe('`pythinker web` option threading', () => { debugEndpoints: true, insecureNoTls: true, allowRemoteShutdown: true, - allowRemoteTerminals: true, dangerousBypassAuth: true, allowedHosts: ['.example.com'], }); 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/pythinker-code/test/utils/plugin-marketplace.test.ts b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts index 642ef5dcd..cea0c9315 100644 --- a/apps/pythinker-code/test/utils/plugin-marketplace.test.ts +++ b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts @@ -6,7 +6,13 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app'; -import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { + computeUpdateStatus, + loadPluginMarketplace, + withBuiltInEntries, + withMarketplaceLatestVersions, + type PluginMarketplaceEntry, +} from '#/utils/plugin-marketplace'; const REPO_ROOT = join(import.meta.dirname, '../../../..'); @@ -575,4 +581,127 @@ describe('loadPluginMarketplace', () => { ); }); + describe('two-phase version lookup', () => { + async function writeCatalog(dir: string) { + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { id: 'demo', displayName: 'Demo', source: 'https://github.com/owner/repo' }, + ], + }), + 'utf8', + ); + return file; + } + + it('skipLatestVersions returns the catalog without querying GitHub', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = await writeCatalog(dir); + + const marketplace = await loadPluginMarketplace({ + workDir: dir, + source: file, + fetchImpl, + skipLatestVersions: true, + }); + + expect(marketplace.plugins[0]?.version).toBeUndefined(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('withMarketplaceLatestVersions fills versions from the latest release redirect', async () => { + const fetchImpl = vi.fn(async (input: unknown) => ({ + ok: false, + status: 302, + headers: new Headers({ + location: 'https://github.com/owner/repo/releases/tag/v1.2.3', + }), + text: async () => '', + })) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = await writeCatalog(dir); + const marketplace = await loadPluginMarketplace({ + workDir: dir, + source: file, + skipLatestVersions: true, + }); + + const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://github.com/owner/repo/releases/latest', + expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }), + ); + expect(enriched.plugins[0]?.version).toBe('1.2.3'); + }); + + it('withMarketplaceLatestVersions degrades to a missing version when the lookup aborts', async () => { + const fetchImpl = vi.fn(async (_input: unknown, init?: { signal?: AbortSignal }) => { + // Simulate the lookup hitting the timeout: undici rejects with the + // signal's reason once the AbortSignal fires. + throw init?.signal?.aborted === true + ? init.signal.reason + : new DOMException('This operation was aborted', 'AbortError'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = await writeCatalog(dir); + const marketplace = await loadPluginMarketplace({ + workDir: dir, + source: file, + skipLatestVersions: true, + }); + + const enriched = await withMarketplaceLatestVersions(marketplace, fetchImpl); + + expect(enriched.plugins[0]?.version).toBeUndefined(); + expect(enriched.plugins[0]?.id).toBe('demo'); + }); + + it('carries a resolved catalog version onto a built-in row injected after enrichment', async () => { + // Regression for the resolve-before-inject ordering: enriching the + // built-in-masked marketplace cannot see the catalog entry's GitHub + // source, so built-in rows would never get update badges. + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 302, + headers: new Headers({ + location: 'https://github.com/owner/repo/releases/tag/v2.0.0', + }), + text: async () => '', + })) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', displayName: 'Demo', source: 'https://github.com/owner/repo' }], + }), + 'utf8', + ); + const catalog = await loadPluginMarketplace({ + workDir: dir, + source: file, + skipLatestVersions: true, + }); + const builtIns: readonly PluginMarketplaceEntry[] = [ + { id: 'demo', displayName: 'Demo Capability', source: 'capability:demo', builtIn: true }, + ]; + + const enriched = withBuiltInEntries( + await withMarketplaceLatestVersions(catalog, fetchImpl), + builtIns, + ); + + expect(enriched.plugins).toHaveLength(1); + expect(enriched.plugins[0]).toEqual( + expect.objectContaining({ id: 'demo', builtIn: true, version: '2.0.0' }), + ); + }); + }); + }); 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/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/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/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/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/_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/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/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/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/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/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/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/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/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/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/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/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/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-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/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..2472b3872 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,124 @@ 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 () => { + vi.useFakeTimers(); + 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 promise = service.request(); + await vi.advanceTimersByTimeAsync(0); + expect(calls.value).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await promise; + + expect(calls.value).toBe(2); + }); + + 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/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index ad50fc27e..00b0e5b97 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'; @@ -69,10 +71,12 @@ describe('Agent loop', () => { [wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "