diff --git a/.changeset/anthropic-undefined-model.md b/.changeset/anthropic-undefined-model.md new file mode 100644 index 000000000..c1efc3034 --- /dev/null +++ b/.changeset/anthropic-undefined-model.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix a crash when a model config entry lacks its model name. diff --git a/.changeset/background-env-bindings.md b/.changeset/background-env-bindings.md new file mode 100644 index 000000000..74b2154a8 --- /dev/null +++ b/.changeset/background-env-bindings.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add environment variable overrides for the background Bash task timeout and the print-mode background policy. diff --git a/.changeset/cron-replay-boundaries.md b/.changeset/cron-replay-boundaries.md new file mode 100644 index 000000000..17ea23f84 --- /dev/null +++ b/.changeset/cron-replay-boundaries.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix slow resume replay for sessions with many cron turns. diff --git a/.changeset/session-index-stray-files.md b/.changeset/session-index-stray-files.md new file mode 100644 index 000000000..d86106fde --- /dev/null +++ b/.changeset/session-index-stray-files.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix recent sessions missing from the session list when the sessions folder contains stray files. diff --git a/.changeset/warn-trust-gated-mcp.md b/.changeset/warn-trust-gated-mcp.md new file mode 100644 index 000000000..d1c53461e --- /dev/null +++ b/.changeset/warn-trust-gated-mcp.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-code-sdk": patch +--- + +Warn in print mode when an untrusted folder skips project-level MCP servers. Workspace trust info now reports project servers that override same-named user entries as trust-gated. diff --git a/.changeset/watch-user-skill-roots.md b/.changeset/watch-user-skill-roots.md new file mode 100644 index 000000000..770ab8a86 --- /dev/null +++ b/.changeset/watch-user-skill-roots.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Refresh the skill catalog automatically when user-level skills are created, changed, or deleted while Pythinker Code is running. diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index 103e0e51a..e4e5a178d 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -128,7 +128,7 @@ export type RemoteControlStatus = export interface RemoteControlOptions { readonly homeDir: string; readonly localOrigin: string; - readonly localServerToken: string; + readonly localServerToken: string | (() => string); readonly relayKey: string; readonly relayOrigin?: string; readonly stderr?: Pick; @@ -340,7 +340,10 @@ function scriptStringLiteral(value: string): string { export async function startRemoteControl( options: RemoteControlOptions, ): Promise { - if (options.localServerToken.length === 0) { + const tokenOption = options.localServerToken; + const resolveServerToken: () => string = + typeof tokenOption === 'function' ? tokenOption : () => tokenOption; + if (resolveServerToken().length === 0) { throw new Error('Remote Control requires local server authentication.'); } if (options.relayKey.length === 0) { @@ -359,6 +362,7 @@ export async function startRemoteControl( }); const client = new RemoteControlClient({ ...options, + localServerToken: resolveServerToken, relayOrigin, deviceId, relayToken: options.relayKey, @@ -374,15 +378,18 @@ export async function startRemoteControl( deviceName, url, close: async () => { - await client.close(); - await lock.release(); + try { + await client.close(); + } finally { + await lock.release(); + } }, }; } class RemoteControlClient { private readonly localOrigin: string; - private readonly localServerToken: string; + private readonly localServerToken: () => string; private readonly relayOrigin: string; private readonly deviceId: string; private readonly relayToken: string; @@ -410,6 +417,7 @@ class RemoteControlClient { readonly relayOrigin: string; readonly deviceId: string; readonly relayToken: string; + readonly localServerToken: () => string; }, ) { this.localOrigin = options.localOrigin.replace(/\/+$/, ''); @@ -654,7 +662,7 @@ class RemoteControlClient { const response = await requestLocalHttp( this.localOrigin, parsed, - this.localServerToken, + this.localServerToken(), this.publicPrefix(), ); this.sendHttpResponse(requestId, response); @@ -693,7 +701,7 @@ class RemoteControlClient { try { local = await connectWebSocket( localWebSocketUrl(this.localOrigin, path), - this.localServerToken, + this.localServerToken(), relayHeaders(payload['headers']), earlyLocalFrames, ); diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index acc20fd3a..4abff9fee 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -259,7 +259,7 @@ export async function handleWebCommand( remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({ homeDir: dataDir, localOrigin: origin, - localServerToken: token, + localServerToken: () => deps.resolveToken?.() ?? '', relayKey, relayOrigin, stderr: deps.stderr, diff --git a/apps/pythinker-code/src/cli/v2/run-v2-print.ts b/apps/pythinker-code/src/cli/v2/run-v2-print.ts index 5551144ba..1a18ee3f3 100644 --- a/apps/pythinker-code/src/cli/v2/run-v2-print.ts +++ b/apps/pythinker-code/src/cli/v2/run-v2-print.ts @@ -30,7 +30,9 @@ import { IBootstrapService, IConfigService, IEventBus, + IHostFileSystem, ISessionIndex, + IWorkspaceInstanceManager, ISessionManager, ITelemetryService, PRINT_MAX_TURNS_DEFAULT, @@ -54,8 +56,13 @@ import { type ISessionScopeHandle, type LoopRunResult, type PrintBackgroundMode, + type McpServerConfig, type Scope, } from '@pymodel/agent-core-v2'; +import { + loadMcpServersDetailed, + resolveMcpJsonPaths, +} from '@pymodel/agent-core-v2/app/mcpConfig/configLoader'; import { createPythinkerDefaultHeaders, createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; import type { GoalUpdated } from '@pymodel/agent-core-v2/features/goal/goalOps'; import type { TurnEnded } from '@pymodel/agent-core-v2/agent/loop/turnOps'; @@ -217,6 +224,13 @@ export async function runV2Print( ); } + try { + const gated = await listTrustGatedMcpServers(app, workDir, homeDir); + if (gated.length > 0) stderr.write(formatTrustGatedMcpWarning(gated)); + } catch { + // Best-effort: a broken mcp.json or trust store must not fail the run. + } + const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); restorePermission = resolved.restorePermission; @@ -266,6 +280,58 @@ interface ResolvedNativeSession { readonly goalModel: string | undefined; } +export interface TrustGatedMcpServer { + readonly name: string; + readonly target: string; +} + +export async function listTrustGatedMcpServers( + app: Scope, + workDir: string, + homeDir: string, +): Promise { + const workspace = await app.accessor + .get(IWorkspaceInstanceManager) + .getOrCreate({ root: workDir }); + if (await workspace.program.trust.get()) return []; + const fs = app.accessor.get(IHostFileSystem); + const [paths, loaded] = await Promise.all([ + resolveMcpJsonPaths({ fs, cwd: workDir, homeDir }), + loadMcpServersDetailed({ fs, cwd: workDir, homeDir, includeProject: true }), + ]); + const projectPaths = new Set([paths.projectRoot, paths.project]); + return Object.entries(loaded.servers) + .filter(([name]) => projectPaths.has(loaded.origins[name] ?? '')) + .map(([name, config]) => ({ name, target: describeMcpTarget(config) })) + .toSorted((a, b) => a.name.localeCompare(b.name)); +} + +export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer[]): string { + const noun = servers.length === 1 ? 'server' : 'servers'; + const list = servers + .map((server) => `${escapeControlChars(server.name)} (${escapeControlChars(server.target)})`) + .join(', '); + return ( + `Warning: this folder is not trusted; skipped ${servers.length} project-level MCP ${noun}: ${list}.\n` + + ' Run `pythinker` here and choose "Trust this folder" to enable them.\n\n' + ); +} + +function escapeControlChars(value: string): string { + return value.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, (char) => { + const code = char.codePointAt(0) ?? 0; + return `\\x${code.toString(16).padStart(2, '0')}`; + }); +} + +function describeMcpTarget(config: McpServerConfig): string { + if (config.transport === 'stdio') { + const args = config.args === undefined ? '' : ` ${config.args.join(' ')}`; + return `stdio: ${config.command}${args}`; + } + return `${config.transport}: ${config.url}`; +} + async function resolveNativeSession( app: Scope, opts: CLIOptions, diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 2b4a7f887..38588086f 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -93,7 +93,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis remoteControl = await startRemoteControl({ homeDir: dataDir, localOrigin: origin, - localServerToken: token, + localServerToken: () => tryResolveServerToken(dataDir) ?? '', relayKey, relayOrigin, onStatus, diff --git a/apps/pythinker-code/src/utils/usage/debug-timing.ts b/apps/pythinker-code/src/utils/usage/debug-timing.ts index bec5d73cb..7fc4b9b50 100644 --- a/apps/pythinker-code/src/utils/usage/debug-timing.ts +++ b/apps/pythinker-code/src/utils/usage/debug-timing.ts @@ -24,6 +24,7 @@ export interface StepTimingInput { */ readonly llmServerDecodeMs?: number; readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; readonly usage?: DebugTokenUsage; } @@ -119,7 +120,9 @@ function formatDecodeSplit(input: StepTimingInput): string { const server = input.llmServerDecodeMs; const client = input.llmClientConsumeMs; if (server === undefined || client === undefined) return ''; - return `; server ${formatDuration(server)} + client ${formatDuration(client)}`; + const blocked = input.llmClientBlockedMs; + const blockedPart = blocked === undefined ? '' : ` (busy ${formatDuration(blocked)})`; + return `; server ${formatDuration(server)}${blockedPart} + client ${formatDuration(client)}`; } function formatDuration(ms: number): string { diff --git a/apps/pythinker-code/test/cli/run-v2-print.test.ts b/apps/pythinker-code/test/cli/run-v2-print.test.ts index 5c87141e4..678b894ce 100644 --- a/apps/pythinker-code/test/cli/run-v2-print.test.ts +++ b/apps/pythinker-code/test/cli/run-v2-print.test.ts @@ -4,9 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { applyPrintBackgroundPolicy, createPrintTurnEndings, + formatTrustGatedMcpWarning, PrintSteeredTurnFailedError, type PrintTurnEnding, type PrintTurnEndings, + type TrustGatedMcpServer, } from '#/cli/v2/run-v2-print'; function ending( @@ -502,3 +504,33 @@ describe('createPrintTurnEndings', () => { await expect(pending).resolves.toMatchObject({ turnId: 7 }); }); }); + +describe('formatTrustGatedMcpWarning', () => { + it('singularizes the noun for one skipped server', () => { + const text = formatTrustGatedMcpWarning([ + { name: 'fs', target: 'stdio: node server.js' }, + ]); + expect(text).toContain('skipped 1 project-level MCP server: fs (stdio: node server.js).'); + expect(text).toContain('"Trust this folder"'); + }); + + it('pluralizes and joins multiple skipped servers', () => { + const servers: readonly TrustGatedMcpServer[] = [ + { name: 'api', target: 'http: https://example.test/mcp' }, + { name: 'fs', target: 'stdio: node server.js' }, + ]; + const text = formatTrustGatedMcpWarning(servers); + expect(text).toContain('skipped 2 project-level MCP servers:'); + expect(text).toContain('api (http: https://example.test/mcp), fs (stdio: node server.js)'); + }); + + it('encodes control characters in untrusted server names and targets', () => { + const text = formatTrustGatedMcpWarning([ + { name: 'evil\u001b]0;pwned\u0007', target: 'stdio: node\u001b[6n server.js' }, + ]); + expect(text).toContain('evil\\x1b]0;pwned\\x07'); + expect(text).toContain('stdio: node\\x1b[6n server.js'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u0007'); + }); +}); diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index 126ada2dc..ba0083b14 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -629,6 +629,57 @@ describe('Remote Control tunnel', () => { expect(logs).toContain('DEPLOYING'); expect(handle.url).toContain('?rc=1&from=pythinker_code_cli'); }, 15_000); + it('re-reads the local server token on every forwarded request', async () => { + const homeDir = createRemoteControlHome(); + const relayToken = RELAY_TOKEN; + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + const authorizationHeaders: string[] = []; + const localServer = createServer((_request, response) => { + authorizationHeaders.push(String(_request.headers.authorization)); + response.writeHead(200, { 'Content-Type': 'text/plain' }); + response.end('ok'); + }); + const localPort = await listen(localServer); + cleanups.push(() => closeServer(localServer)); + + let currentToken = 'local-server-token'; + handle = await startRemoteControl({ + homeDir, + localOrigin: `http://127.0.0.1:${localPort}`, + localServerToken: () => currentToken, + relayKey: relayToken, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + const rawRequest = Buffer.from('GET / HTTP/1.1\r\nHost: relay.test\r\n\r\n'); + const forward = async (): Promise => { + const responsePromise = nextJsonMessage(relay.httpSockets.at(-1)!); + relay.httpSockets.at(-1)!.send( + JSON.stringify({ + request_id: `request-${String(authorizationHeaders.length)}`, + type: 'request', + is_last: true, + body_base64: rawRequest.toString('base64'), + }), + ); + await responsePromise; + }; + + await forward(); + expect(authorizationHeaders).toEqual(['Bearer local-server-token']); + + currentToken = 'rotated-server-token'; + await forward(); + expect(authorizationHeaders).toEqual([ + 'Bearer local-server-token', + 'Bearer rotated-server-token', + ]); + }); + }); describe('Remote Control single-instance lock', () => { diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index e1f96ffae..2de97f966 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -497,9 +497,13 @@ describe('`pythinker web` opens the browser', () => { expect.objectContaining({ relayOrigin: 'https://relay.example.test', relayKey: 'relay-key-1', - localServerToken: 'tok-1', + localServerToken: expect.any(Function), }), ); + const options = (startRemoteControl.mock.calls as unknown as[ + [{ localServerToken: () => string }], + ])[0]?.[0]; + expect(options?.localServerToken()).toBe('tok-1'); }); it('refuses to start Remote Control without a relay key', async () => { diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index 2b33188b3..afd4b0994 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -246,9 +246,11 @@ describe('handleRemoteControlCommand', () => { expect.objectContaining({ homeDir: dataDir, localOrigin: 'http://127.0.0.1:58627', - localServerToken: 'local-server-token', + localServerToken: expect.any(Function), }), ); + const options = mocks.startRemoteControl.mock.calls[0]?.[0]; + expect(options.localServerToken()).toBe('local-server-token'); expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl); const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); expect(written).toContain('Pythinker Remote Control ready'); diff --git a/apps/pythinker-code/test/utils/usage/debug-timing.test.ts b/apps/pythinker-code/test/utils/usage/debug-timing.test.ts index 98bb97ae3..392c8ddb5 100644 --- a/apps/pythinker-code/test/utils/usage/debug-timing.test.ts +++ b/apps/pythinker-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,20 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('appends the blocked share to the decode split when present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 6000, + llmServerDecodeMs: 6000, + llmClientConsumeMs: 25, + llmClientBlockedMs: 4875, + usage: { output: 216 }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 36.0 tok/s (216 tokens in 6.0s; server 6.0s (busy 4.9s) + client 25ms)', + ); + }); + it('formats input tokens and cache read/write counts', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 800, diff --git a/apps/vis/web/src/components/analysis/TimelineTab.tsx b/apps/vis/web/src/components/analysis/TimelineTab.tsx index 1b082142e..3c609c092 100644 --- a/apps/vis/web/src/components/analysis/TimelineTab.tsx +++ b/apps/vis/web/src/components/analysis/TimelineTab.tsx @@ -307,9 +307,10 @@ function StepRow({ step, turnDurationMs }: { step: StepNode; turnDurationMs?: nu {step.llmServerDecodeMs !== undefined && step.llmClientConsumeMs !== undefined ? ( decode {step.llmServerDecodeMs}+{step.llmClientConsumeMs}ms + {step.llmClientBlockedMs !== undefined ? ` (busy ${step.llmClientBlockedMs}ms)` : ''} ) : null} {step.contextTokens !== undefined ? ( diff --git a/apps/vis/web/src/components/wire/parts.tsx b/apps/vis/web/src/components/wire/parts.tsx index 0bd80e2e9..bcc056272 100644 --- a/apps/vis/web/src/components/wire/parts.tsx +++ b/apps/vis/web/src/components/wire/parts.tsx @@ -397,6 +397,11 @@ export function LoopEventDetail({ event }: { event: LoopRecordedEvent }) { {event.llmClientConsumeMs} ms ) : null} + {event.llmClientBlockedMs !== undefined ? ( + + {event.llmClientBlockedMs} ms + + ) : null} {usage !== undefined ? (
diff --git a/apps/vis/web/src/lib/analysis.ts b/apps/vis/web/src/lib/analysis.ts index b84cffe4b..1774cb14c 100644 --- a/apps/vis/web/src/lib/analysis.ts +++ b/apps/vis/web/src/lib/analysis.ts @@ -60,6 +60,7 @@ export interface StepNode { /** Decode split: server time awaiting parts vs. client time processing them. */ llmServerDecodeMs?: number; llmClientConsumeMs?: number; + llmClientBlockedMs?: number; content: ContentSummary; toolCalls: ToolCallNode[]; } @@ -375,6 +376,7 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { step.llmServerFirstTokenMs = ev.llmServerFirstTokenMs; step.llmServerDecodeMs = ev.llmServerDecodeMs; step.llmClientConsumeMs = ev.llmClientConsumeMs; + step.llmClientBlockedMs = ev.llmClientBlockedMs; if (step.beginTime !== undefined && t !== undefined) step.durationMs = t - step.beginTime; // Steps don't carry a generic 'error' finish reason (errors are // thrown, not recorded). 'filtered' means the provider blocked the diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 185170a20..cc1cd8fd4 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -377,7 +377,7 @@ Retries only apply to transient failures — connection errors, timeouts, HTTP 4 | `print_wait_ceiling_s` | `integer` | `2147483` | In print mode (`pythinker -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"` (the default is ~24.8 days — effectively unbounded). Has no effect outside print mode or when it is `"exit"` | | `print_max_turns` | `integer` | `100000` | In print mode (`pythinker -p`) with `print_background_mode = "steer"`, the maximum number of new turns that may be triggered by background-task completions, to keep the steering loop bounded (the default is effectively unbounded) | -`keep_alive_on_exit` can be overridden by the `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, and `max_running_tasks` by `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS`; both take higher priority than `config.toml`. +`keep_alive_on_exit` can be overridden by the `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` environment variable, `max_running_tasks` by `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS`, `bash_task_timeout_s` by `PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S`, and `print_background_mode`, `print_wait_ceiling_s`, and `print_max_turns` by `PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE`, `PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S`, and `PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS`; all take higher priority than `config.toml`. In print mode (`pythinker -p ""`), Pythinker Code stays alive after the main agent's turn as long as background tasks are still pending: each completion is fed back to the main agent as a synthetic user message, steering it into a new turn (`print_background_mode = "steer"` by default), and the run exits once a turn ends with nothing pending. The loop is bounded by `print_wait_ceiling_s` and `print_max_turns`, both effectively unbounded by default. Background work is never killed by a wall-clock cap in print mode either: background `Bash` tasks default to no timeout (`bash_task_timeout_s = 0`), and subagents run without a timeout (`[subagent] timeout_ms` and `[dynamic_workflow] timeout_ms` both default to `0` unless explicitly set), so only the model itself stops a task. Set `print_background_mode` to `"drain"` to wait for tasks without feeding results back, or `"exit"` to end the run as soon as the main agent finishes. diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 99609ddcc..a58bd2ecc 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -126,6 +126,10 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_PASSWORD` | Set a parallel auth credential for the `pythinker web` local server, valid alongside the bearer token; recommended when binding the server beyond loopback — see [Local server and API](../guides/server.md#authentication) | Any non-empty string; when unset, only the token is valid | | `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS` | Cap on concurrently running background tasks; takes higher priority than `[background] max_running_tasks` in `config.toml` (unset means no cap) | Positive integer; invalid values are ignored | +| `PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S` | Default timeout (seconds) for background `Bash` tasks, also used to re-arm foreground commands moved to the background; takes higher priority than `[task] bash_task_timeout_s` (`0` means no timeout) | Non-negative integer; invalid values are ignored | +| `PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE` | What `pythinker -p` does while background tasks are still pending after the main turn; takes higher priority than `[task] print_background_mode` | `exit`, `drain`, or `steer`; invalid values are ignored | +| `PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S` | Wall-clock ceiling (seconds) for the print-mode drain/steer wait; takes higher priority than `[task] print_wait_ceiling_s` | Positive integer; invalid values are ignored | +| `PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS` | Maximum number of new turns triggered by background-task completions in print mode; takes higher priority than `[task] print_max_turns` | Positive integer; invalid values are ignored | | `PYTHINKER_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored | | `PYTHINKER_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored | | `PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | Unset (no default catalog; unset means only built-in entries are shown); accepts `http://`, `file://` URLs, and local paths | diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 6f15f69b8..f67a35027 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -59,6 +59,10 @@ enabled = false # env: # keep_alive_on_exit <- PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) # max_running_tasks <- PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# bash_task_timeout_s <- PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse) +# print_wait_ceiling_s <- PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse) +# print_background_mode <- PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse) +# print_max_turns <- PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse) # ########################################################################## [background] @@ -431,6 +435,10 @@ timeout_ms = 7200000 # env: # keep_alive_on_exit <- PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT (custom parse) # max_running_tasks <- PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS (custom parse) +# bash_task_timeout_s <- PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S (custom parse) +# print_wait_ceiling_s <- PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S (custom parse) +# print_background_mode <- PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE (custom parse) +# print_max_turns <- PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS (custom parse) # ########################################################################## [task] diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index cf68b8c9f..3db143057 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -30,6 +30,7 @@ export type LoopRecordedEvent = readonly llmServerFirstTokenMs?: number; readonly llmServerDecodeMs?: number; readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; readonly messageId?: string; readonly providerFinishReason?: FinishReason; readonly rawFinishReason?: string; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 252ad06c1..fbb0c6195 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -815,6 +815,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } if (timing.serverDecodeMs !== undefined) payload['serverDecodeMs'] = timing.serverDecodeMs; if (timing.clientConsumeMs !== undefined) payload['clientConsumeMs'] = timing.clientConsumeMs; + if (timing.clientBlockedMs !== undefined) payload['clientBlockedMs'] = timing.clientBlockedMs; this.log.info('llm response', payload); } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index de16d2cd5..3364f257d 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -1041,6 +1041,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { llmServerFirstTokenMs: timing?.serverFirstTokenMs, llmServerDecodeMs: timing?.serverDecodeMs, llmClientConsumeMs: timing?.clientConsumeMs, + llmClientBlockedMs: timing?.clientBlockedMs, messageId: response.providerMessageId, providerFinishReason: response.providerFinishReason, rawFinishReason: response.rawFinishReason, @@ -1102,6 +1103,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { llmServerFirstTokenMs: response.timing?.serverFirstTokenMs, llmServerDecodeMs: response.timing?.serverDecodeMs, llmClientConsumeMs: response.timing?.clientConsumeMs, + llmClientBlockedMs: response.timing?.clientBlockedMs, providerFinishReason: response.providerFinishReason, rawFinishReason: response.rawFinishReason, }), diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index d3e9fdebe..0e81be0b3 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -123,6 +123,7 @@ export interface TurnStepCompletedPayload { readonly llmServerFirstTokenMs?: number; readonly llmServerDecodeMs?: number; readonly llmClientConsumeMs?: number; + readonly llmClientBlockedMs?: number; readonly providerFinishReason?: FinishReason; readonly rawFinishReason?: string; } diff --git a/packages/agent-core-v2/src/agent/task/configSection.ts b/packages/agent-core-v2/src/agent/task/configSection.ts index 1517d1ef0..9aec998f6 100644 --- a/packages/agent-core-v2/src/agent/task/configSection.ts +++ b/packages/agent-core-v2/src/agent/task/configSection.ts @@ -45,17 +45,37 @@ export function resolvePrintBackgroundMode(config: IConfigService): PrintBackgro export const KEEP_ALIVE_ON_EXIT_ENV = 'PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT'; export const MAX_RUNNING_TASKS_ENV = 'PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS'; +export const BASH_TASK_TIMEOUT_S_ENV = 'PYTHINKER_CODE_BACKGROUND_BASH_TASK_TIMEOUT_S'; +export const PRINT_WAIT_CEILING_S_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_WAIT_CEILING_S'; +export const PRINT_BACKGROUND_MODE_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_BACKGROUND_MODE'; +export const PRINT_MAX_TURNS_ENV = 'PYTHINKER_CODE_BACKGROUND_PRINT_MAX_TURNS'; function parsePositiveInt(raw: string): number | undefined { const value = raw.trim(); if (value.length === 0 || !/^\d+$/.test(value)) return undefined; const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function parseNonNegativeInt(raw: string): number | undefined { + const value = raw.trim(); + if (value.length === 0 || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function parsePrintBackgroundMode(raw: string): PrintBackgroundMode | undefined { + const parsed = PrintBackgroundModeSchema.safeParse(raw.trim()); + return parsed.success ? parsed.data : undefined; } export const taskEnvBindings: EnvBindings = envBindings(AgentTaskConfigSchema, { keepAliveOnExit: { env: KEEP_ALIVE_ON_EXIT_ENV, parse: parseBooleanEnv }, maxRunningTasks: { env: MAX_RUNNING_TASKS_ENV, parse: parsePositiveInt }, + bashTaskTimeoutS: { env: BASH_TASK_TIMEOUT_S_ENV, parse: parseNonNegativeInt }, + printWaitCeilingS: { env: PRINT_WAIT_CEILING_S_ENV, parse: parsePositiveInt }, + printBackgroundMode: { env: PRINT_BACKGROUND_MODE_ENV, parse: parsePrintBackgroundMode }, + printMaxTurns: { env: PRINT_MAX_TURNS_ENV, parse: parsePositiveInt }, }); export const stripTaskEnv = stripEnvBoundFields(taskEnvBindings); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts index 774bead61..ec2130319 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -198,14 +198,14 @@ export class SessionIndexProjector { } private async scanAuthoritative(): Promise { - const { storage, docs, sessionsScope } = this.deps; + const { storage, docs, sessionsScope, log } = this.deps; const summaries: SessionSummary[] = []; const counts = new Map(); let sourceMaxMtimeMs = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0; for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => { - const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId); + const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log); if (mtime > sourceMaxMtimeMs) sourceMaxMtimeMs = mtime; return readSessionSummary(docs, sessionsScope, workspaceId, sessionId); }); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 0336ec45d..f8d3c11e5 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -163,7 +163,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { const published = manifest.sourceMaxMtimeMs; if (published === undefined) return false; try { - return (await scanSessionsMaxMtime(this.storage, this.sessionsScope)) <= published; + return (await scanSessionsMaxMtime(this.storage, this.sessionsScope, this.log)) <= published; } catch (error) { this.log.warn('session index freshness check failed; re-projecting', { error: String(error), diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts index 9c75068f0..4ef658aca 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -1,6 +1,11 @@ +import { ILogService } from '#/_base/log/log'; import { SESSION_INDEX_KEY, SESSION_INDEX_SCOPE } from '#/app/workspace/workspaceAlias'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { + IFileSystemStorageService, + StorageError, + StorageErrors, +} from '#/persistence/interface/storage'; import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex'; @@ -171,27 +176,49 @@ export async function mapBounded( return out; } +async function stateFileMtime( + storage: IFileSystemStorageService, + scope: string, + log: ILogService | undefined, +): Promise { + try { + return await storage.mtime(scope, META_KEY); + } catch (error) { + if ( + error instanceof StorageError && + error.code === StorageErrors.codes.STORAGE_IO_FAILED && + error.details?.['errno'] === 'ENOTDIR' + ) { + log?.warn('session index skips a non-directory entry'); + return undefined; + } + throw error; + } +} + export async function sessionStateMaxMtime( storage: IFileSystemStorageService, sessionsScope: string, workspaceId: string, sessionId: string, + log?: ILogService, ): Promise { const base = `${sessionsScope}/${workspaceId}/${sessionId}`; - const direct = await storage.mtime(base, META_KEY); - const nested = await storage.mtime(`${base}/${META_SCOPE}`, META_KEY); + const direct = await stateFileMtime(storage, base, log); + const nested = await stateFileMtime(storage, `${base}/${META_SCOPE}`, log); return Math.max(direct ?? 0, nested ?? 0); } export async function scanSessionsMaxMtime( storage: IFileSystemStorageService, sessionsScope: string, + log?: ILogService, ): Promise { let max = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0; for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); const mtimes = await mapBounded(sessionIds, MTIME_SCAN_CONCURRENCY, (sessionId) => - sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId), + sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log), ); for (const mtime of mtimes) { if (mtime > max) max = mtime; diff --git a/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts b/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts index d705059ed..5f252fa8b 100644 --- a/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/features/skill/catalog/userFileSkillSource.ts @@ -1,10 +1,15 @@ +import { join } from 'pathe'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; +import { TimeoutTimer } from '#/_base/utils/timer'; +import { subtreeWatchFilter } from '#/_base/utils/paths'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -21,6 +26,8 @@ export interface IUserFileSkillSource extends ISkillSource { export const IUserFileSkillSource: ServiceIdentifier = createDecorator('userFileSkillSource'); +const WATCH_DEBOUNCE_MS = 200; + export class UserFileSkillSource extends Disposable implements IUserFileSkillSource { declare readonly _serviceBrand: undefined; @@ -28,11 +35,16 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou readonly priority = SKILL_SOURCE_PRIORITY.user; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watchDebounce = this._register(new TimeoutTimer()); + private readonly watchResources = this._register(new DisposableStore()); + private watchReady: Promise = Promise.resolve(); constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, + @IHostFsWatchService private readonly fsWatch: IHostFsWatchService, + @IHostFileSystem private readonly hostFs: IHostFileSystem, ) { super(); this._register( @@ -40,9 +52,13 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); }), ); + if ((this.bootstrap.args.skillDirs?.length ?? 0) === 0) { + this.watchUserSkillRoots(); + } } async load(): Promise { + await this.watchReady; if ((this.bootstrap.args.skillDirs?.length ?? 0) > 0) { return { skills: [] }; } @@ -53,6 +69,43 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }), ); } + + private watchUserSkillRoots(): void { + const candidatesByBase = new Map(); + for (const [base, root] of [ + [this.bootstrap.homeDir, join(this.bootstrap.homeDir, 'skills')], + [this.bootstrap.osHomeDir, join(this.bootstrap.osHomeDir, '.agents', 'skills')], + ] as const) { + const existing = candidatesByBase.get(base); + if (existing === undefined) candidatesByBase.set(base, [root]); + else existing.push(root); + } + const ready: Promise[] = []; + for (const [base, candidates] of candidatesByBase) { + ready.push( + this.hostFs.stat(base).then( + (stat) => { + if (!stat.isDirectory) return; + const handle = this.fsWatch.watch(base, { + ignored: subtreeWatchFilter(base, candidates), + signal: true, + }); + this.watchResources.add(handle); + this.watchResources.add( + handle.onDidChange(() => { + this.watchDebounce.cancelAndSet(() => { + this.onDidChangeEmitter.fire(); + }, WATCH_DEBOUNCE_MS); + }), + ); + return handle.ready; + }, + () => undefined, + ), + ); + } + this.watchReady = Promise.allSettled(ready).then(() => undefined); + } } registerScopedService( diff --git a/packages/agent-core-v2/src/kosong/contract/generate.ts b/packages/agent-core-v2/src/kosong/contract/generate.ts index e23aa8404..dd667b95d 100644 --- a/packages/agent-core-v2/src/kosong/contract/generate.ts +++ b/packages/agent-core-v2/src/kosong/contract/generate.ts @@ -1,3 +1,5 @@ +import { performance, type EventLoopUtilization } from 'node:perf_hooks'; + import { APIEmptyResponseError, createAbortError } from './errors'; import { isContentPart, @@ -38,6 +40,7 @@ export async function generate( ): Promise { const message: Message = { role: 'assistant', content: [], toolCalls: [] }; let pendingPart: StreamedMessagePart | null = null; + let deferredThink: StreamedMessagePart | null = null; const toolCallIndexMap = new Map(); @@ -61,11 +64,13 @@ export async function generate( let clientConsumeMs = 0; let firstPartAt: number | undefined; let lastResumeAt = 0; + let decodeEluStart: EventLoopUtilization | undefined; for await (const part of stream) { const arrivedAt = Date.now(); if (firstPartAt === undefined) { firstPartAt = arrivedAt; + decodeEluStart = performance.eventLoopUtilization(); } else { serverDecodeMs += arrivedAt - lastResumeAt; } @@ -96,10 +101,22 @@ export async function generate( } } + if (part.type === 'text') deferredThink = null; if (pendingPart === null) { pendingPart = part; + } else if ( + pendingPart.type === 'text' && + part.type === 'think' && + part.encrypted === undefined && + part.think.trim().length === 0 + ) { + deferredThink = part; } else if (!mergeInPlace(pendingPart, part)) { flushPart(message, pendingPart, toolCallIndexMap); + if (deferredThink !== null) { + flushPart(message, deferredThink, toolCallIndexMap); + deferredThink = null; + } pendingPart = part; } } finally { @@ -112,13 +129,22 @@ export async function generate( if (firstPartAt !== undefined) { serverDecodeMs += Date.now() - lastResumeAt; } + const elu = + firstPartAt === undefined || decodeEluStart === undefined + ? undefined + : performance.eventLoopUtilization(decodeEluStart); + const clientBlockedMs = + elu === undefined ? undefined : Math.max(0, Math.round(elu.active) - clientConsumeMs); options?.onStreamEnd?.( - firstPartAt === undefined ? undefined : { serverDecodeMs, clientConsumeMs }, + firstPartAt === undefined ? undefined : { serverDecodeMs, clientConsumeMs, clientBlockedMs }, ); if (pendingPart !== null) { flushPart(message, pendingPart, toolCallIndexMap); } + if (deferredThink !== null) { + flushPart(message, deferredThink, toolCallIndexMap); + } if (message.content.length === 0 && message.toolCalls.length === 0) { throw new APIEmptyResponseError( 'The API returned an empty response (no content, no tool calls).' + diff --git a/packages/agent-core-v2/src/kosong/contract/provider.ts b/packages/agent-core-v2/src/kosong/contract/provider.ts index b881eaf8d..3b4496000 100644 --- a/packages/agent-core-v2/src/kosong/contract/provider.ts +++ b/packages/agent-core-v2/src/kosong/contract/provider.ts @@ -62,6 +62,7 @@ export interface ToolCallIdPolicy { export interface StreamDecodeStats { readonly serverDecodeMs: number; readonly clientConsumeMs: number; + readonly clientBlockedMs?: number; } export interface VideoUploadInput { diff --git a/packages/agent-core-v2/src/kosong/model/modelRequester.ts b/packages/agent-core-v2/src/kosong/model/modelRequester.ts index 573b35707..9591ab926 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequester.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequester.ts @@ -25,6 +25,7 @@ export interface ModelRequestTiming { readonly serverFirstTokenMs?: number; readonly serverDecodeMs?: number; readonly clientConsumeMs?: number; + readonly clientBlockedMs?: number; } export type ModelRequestEvent = diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts index 80e06260d..26bfcc147 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts @@ -217,6 +217,9 @@ export function buildStreamTiming( if (decodeStats !== undefined) { timing.serverDecodeMs = Math.max(0, decodeStats.serverDecodeMs); timing.clientConsumeMs = Math.max(0, decodeStats.clientConsumeMs); + if (decodeStats.clientBlockedMs !== undefined) { + timing.clientBlockedMs = Math.max(0, decodeStats.clientBlockedMs); + } } return timing; } diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index 17fc565b1..ec842a371 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -184,9 +184,13 @@ class SignalWatchHandle implements IHostFsWatchHandle { this.fireInvalidation(); return; } - onUnexpectedError(error); + if (error.code === 'ENOENT') { + this.readiness.resolve(); + } else { + onUnexpectedError(error); + this.fireInvalidation(); + } this.recovering = true; - this.fireInvalidation(); const delay = Math.min(NATIVE_RETRY_BASE_MS * 2 ** this.retryAttempts, NATIVE_RETRY_MAX_MS); this.retryAttempts += 1; this.retry?.dispose(); diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts index 56d3f3e82..5b7c137fb 100644 --- a/packages/agent-core-v2/src/program/program.ts +++ b/packages/agent-core-v2/src/program/program.ts @@ -298,7 +298,7 @@ export class Program { const extraAgentProfiles = own(new ExtraAgentProfileLoaderService(this.dependencies.config, this.context, this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, userAgentProfiles, this.dependencies.agentProfiles)); const agentProfiles = own(new WorkspaceAgentProfileLoaderService(this.context, runtime.fs!, this.dependencies.log, userAgentProfiles, runtime.watch!, this.dependencies.agentProfiles)); const skillDiscovery = new RuntimeSkillDiscovery(this.dependencies.log, runtime.fs!); - const userSkills = own(new UserFileSkillSource(skillDiscovery, this.dependencies.bootstrap, this.dependencies.config)); + const userSkills = own(new UserFileSkillSource(skillDiscovery, this.dependencies.bootstrap, this.dependencies.config, runtime.watch!, runtime.fs!)); const explicitSkills = new ExplicitFileSkillSource(skillDiscovery, this.context, this.dependencies.bootstrap); const extraSkills = own(new ExtraFileSkillSource(skillDiscovery, this.dependencies.config, this.context, this.dependencies.bootstrap)); const workspaceSkills = own(new WorkspaceRootSkillSource(skillDiscovery, this.context, this.dependencies.config, this.dependencies.bootstrap, runtime.watch!)); @@ -329,7 +329,7 @@ export class Program { retired: false, }; } catch (error) { - for (const disposable of disposables.reverse()) void disposable.dispose(); + for (const disposable of disposables.toReversed()) void disposable.dispose(); lease.dispose(); throw error; } @@ -368,7 +368,7 @@ export class Program { private releaseGeneration(generation: ProgramGeneration): void { generation.references -= 1; if (generation.references !== 0 || !generation.retired) return; - for (const disposable of [...generation.disposables].reverse()) void disposable.dispose(); + for (const disposable of [...generation.disposables].toReversed()) void disposable.dispose(); generation.lease.dispose(); } 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 e732cbbbb..2660f6a44 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -108,6 +108,25 @@ describe('Agent loop', () => { `); }); + it('merges text across a vacuous reasoning part instead of splitting the text', async () => { + profile.update({ activeToolNames: [] }); + + ctx.mockNextResponse( + { type: 'text', text: '' }, + { type: 'think', think: '' }, + { type: 'text', text: '' }, + ); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + await ctx.untilTurnEnd(); + + const contentParts = (await ctx.persistedWireRecords()) + .filter((entry) => entry.type === 'context.append_loop_event') + .map((entry) => entry['event'] as { type: string; part?: { type: string; text?: string } }) + .filter((event) => event.type === 'content.part') + .map((event) => event.part); + expect(contentParts).toEqual([{ type: 'text', text: '' }]); + }); + it('persists a turn.ended wire record with the end reason and duration', async () => { profile.update({ activeToolNames: [] }); diff --git a/packages/agent-core-v2/test/app/bootstrap/stubs.ts b/packages/agent-core-v2/test/app/bootstrap/stubs.ts index fd2d3f4cc..94a0c3877 100644 --- a/packages/agent-core-v2/test/app/bootstrap/stubs.ts +++ b/packages/agent-core-v2/test/app/bootstrap/stubs.ts @@ -16,6 +16,7 @@ export function stubBootstrap( homeDir = '/tmp/pythinker-home', env: NodeJS.ProcessEnv = {}, args: HostArgsInput = {}, + osHomeDir = '/home/test', ): IBootstrapService { const scopes: Record = { config: '', @@ -31,7 +32,7 @@ export function stubBootstrap( platform: 'linux', arch: 'x64', cwd: '/tmp', - osHomeDir: '/home/test', + osHomeDir, homeDir, configPath: `${homeDir}/config.toml`, configKey: 'config.toml', diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 99c89c9cd..54e807e72 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -69,8 +69,12 @@ import { import '#/app/kosongConfig/envOverlay'; import { type ThinkingConfig } from '#/kosong/model/thinking'; import { + BASH_TASK_TIMEOUT_S_ENV, KEEP_ALIVE_ON_EXIT_ENV, MAX_RUNNING_TASKS_ENV, + PRINT_BACKGROUND_MODE_ENV, + PRINT_MAX_TURNS_ENV, + PRINT_WAIT_CEILING_S_ENV, resolveAgentTaskConfig, resolvePrintBackgroundMode, type AgentTaskConfig, @@ -1549,6 +1553,81 @@ describe('task config section', () => { env[KEEP_ALIVE_ON_EXIT_ENV] = 'true'; expect(resolvePrintBackgroundMode(config)).toBe('drain'); + disposables.dispose(); + }); + it('applies the bashTaskTimeoutS env binding, accepting 0 as no timeout', async () => { + const env: Record = {}; + const { config, disposables } = await createTaskConfig(env); + + expect(config.get('task')?.bashTaskTimeoutS).toBeUndefined(); + + env[BASH_TASK_TIMEOUT_S_ENV] = 'abc'; + expect(config.get('task')?.bashTaskTimeoutS).toBeUndefined(); + env[BASH_TASK_TIMEOUT_S_ENV] = '-5'; + expect(config.get('task')?.bashTaskTimeoutS).toBeUndefined(); + + env[BASH_TASK_TIMEOUT_S_ENV] = '0'; + expect(config.get('task')?.bashTaskTimeoutS).toBe(0); + expect(config.get('background')?.bashTaskTimeoutS).toBe(0); + + env[BASH_TASK_TIMEOUT_S_ENV] = '30'; + expect(config.get('task')?.bashTaskTimeoutS).toBe(30); + + disposables.dispose(); + }); + + it('applies the print policy env bindings and ignores invalid values', async () => { + const env: Record = {}; + const { config, disposables } = await createTaskConfig(env); + + env[PRINT_WAIT_CEILING_S_ENV] = '0'; + expect(config.get('task')?.printWaitCeilingS).toBeUndefined(); + env[PRINT_WAIT_CEILING_S_ENV] = '3600'; + expect(config.get('task')?.printWaitCeilingS).toBe(3600); + + env[PRINT_MAX_TURNS_ENV] = 'abc'; + expect(config.get('task')?.printMaxTurns).toBeUndefined(); + env[PRINT_MAX_TURNS_ENV] = '7'; + expect(config.get('task')?.printMaxTurns).toBe(7); + + env[PRINT_BACKGROUND_MODE_ENV] = 'wait'; + expect(resolvePrintBackgroundMode(config)).toBe('steer'); + env[PRINT_BACKGROUND_MODE_ENV] = 'exit'; + expect(resolvePrintBackgroundMode(config)).toBe('exit'); + env[PRINT_BACKGROUND_MODE_ENV] = ' drain '; + expect(resolvePrintBackgroundMode(config)).toBe('drain'); + + disposables.dispose(); + }); + + it('lets the print policy env bindings override the config values', async () => { + const env: Record = { + [PRINT_BACKGROUND_MODE_ENV]: 'exit', + [PRINT_WAIT_CEILING_S_ENV]: '3600', + }; + const { config, disposables } = await createTaskConfig( + env, + '[task]\nprint_background_mode = "drain"\nprint_wait_ceiling_s = 60\n', + ); + + expect(resolvePrintBackgroundMode(config)).toBe('exit'); + expect(resolveAgentTaskConfig(config)?.printWaitCeilingS).toBe(3600); + + disposables.dispose(); + }); + + it('ignores unsafe integers without discarding sibling env bindings', async () => { + const env: Record = { + [BASH_TASK_TIMEOUT_S_ENV]: '9007199254740992', + [PRINT_WAIT_CEILING_S_ENV]: '9007199254740992', + [PRINT_BACKGROUND_MODE_ENV]: 'exit', + }; + const { config, disposables } = await createTaskConfig(env); + + expect(config.get('task')?.bashTaskTimeoutS).toBeUndefined(); + expect(config.get('task')?.printWaitCeilingS).toBeUndefined(); + expect(resolvePrintBackgroundMode(config)).toBe('exit'); + disposables.dispose(); }); }); @@ -1666,6 +1745,19 @@ describe('applyPrintModeConfigDefaults', () => { disposables.dispose(); }); + it('does not override keys set via env bindings', async () => { + const { config, disposables } = await createConfig({ + [BASH_TASK_TIMEOUT_S_ENV]: '30', + }); + + await applyPrintModeConfigDefaults(config); + + expect(resolveAgentTaskConfig(config)?.bashTaskTimeoutS).toBe(30); + expect(config.inspect('task').memoryValue).toBeUndefined(); + + disposables.dispose(); + }); + }); describe('dynamic workflow config section', () => { diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a8ac95b76..83e09cdc9 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -524,6 +524,22 @@ describe('FileSessionIndex (read model)', () => { expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(2); }); + it('prepare skips stray files and state-less directories instead of failing the projection', async () => { + await seedSession('active', { title: 'hello', createdAt: 1, updatedAt: 2 }); + await fsp.writeFile(join(sessionsDir, 'workspace.json'), '{}'); + await fsp.writeFile(join(sessionsDir, workspaceId, 'workspace.json'), '{}'); + await fsp.writeFile(join(sessionsDir, workspaceId, '.DS_Store'), 'junk'); + await fsp.mkdir(join(sessionsDir, workspaceId, 'no-state'), { recursive: true }); + + const store = build(); + const status = await store.prepare(); + expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 }); + + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['active']); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(1); + }); + it('serves warm reads without touching the session directories', async () => { await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 }); await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 }); diff --git a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts index 1a5da28b8..36134e6e0 100644 --- a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts @@ -17,6 +17,7 @@ import { IPluginService } from '#/app/plugin/plugin'; import { PluginService } from '#/app/plugin/pluginService'; import type { PluginReloadEvent } from '#/app/plugin/types'; import { IProviderService } from '#/kosong/provider/provider'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService, type HostFsChange, @@ -52,6 +53,7 @@ import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; import { FileSkillDiscovery } from '#/features/skill/catalog/fileSkillDiscovery'; import type { SkillRoot } from '#/features/skill/catalog/types'; import { ILogService } from '#/_base/log/log'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; import { stubBootstrap } from '../../../app/bootstrap/stubs'; @@ -164,6 +166,32 @@ function fsWatchStub( }; } +function recordingWatchService(): { + service: IHostFsWatchService; + calls: { path: string; ignored: ((path: string) => boolean) | undefined }[]; + handles: { disposed: boolean }[]; +} { + const calls: { path: string; ignored: ((path: string) => boolean) | undefined }[] = []; + const handles: { disposed: boolean }[] = []; + const service: IHostFsWatchService = { + _serviceBrand: undefined, + watch: (path, options) => { + calls.push({ path, ignored: options?.ignored }); + const handle: IHostFsWatchHandle & { disposed: boolean } = { + ready: Promise.resolve(), + onDidChange: Event.None as Event, + disposed: false, + dispose: () => { + handle.disposed = true; + }, + }; + handles.push(handle); + return handle; + }, + }; + return { service, calls, handles }; +} + function makeHost( store: ISkillDiscovery, ws: IWorkspaceContext, @@ -233,6 +261,7 @@ describe('WorkspaceSkillCatalogService', () => { _clearScopedRegistryForTests(); registerScopedService(LifecycleScope.App, IBuiltinSkillSource, BuiltinSkillSource); registerScopedService(LifecycleScope.App, IUserFileSkillSource, UserFileSkillSource); + registerScopedService(LifecycleScope.App, IHostFileSystem, HostFileSystem); registerScopedService(LifecycleScope.App, IPluginService, PluginService); registerScopedService( 'program', @@ -1096,4 +1125,212 @@ describe('WorkspaceSkillCatalogService', () => { await rm(workDir, { recursive: true, force: true }); } }, 15000); + it('watches both user-level skill roots and prunes unrelated paths', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-home-')); + const osHomeDir = await mkdtemp(join(tmpdir(), 'skill-user-os-')); + await mkdir(join(homeDir, 'skills'), { recursive: true }); + await mkdir(join(osHomeDir, '.agents', 'skills'), { recursive: true }); + const { service, calls } = recordingWatchService(); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap(homeDir, {}, {}, osHomeDir)), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(IHostFsWatchService, service), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + + try { + const source = host.app.accessor.get(IUserFileSkillSource); + await source.load(); + + const home = calls.find((call) => call.path === homeDir); + const osHome = calls.find((call) => call.path === osHomeDir); + expect(home).toBeDefined(); + expect(osHome).toBeDefined(); + expect(home?.ignored?.(join(homeDir, 'skills/demo/SKILL.md'))).toBe(false); + expect(home?.ignored?.(join(homeDir, 'sessions/s1/state.json'))).toBe(true); + expect(osHome?.ignored?.(join(osHomeDir, '.agents/skills/demo/SKILL.md'))).toBe(false); + expect(osHome?.ignored?.(join(osHomeDir, 'Downloads/x.zip'))).toBe(true); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + await rm(osHomeDir, { recursive: true, force: true }); + } + }); + + it('merges both skill-root candidates into one watch when homeDir equals osHomeDir', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-same-')); + await mkdir(join(homeDir, 'skills'), { recursive: true }); + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }); + const { service, calls } = recordingWatchService(); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap(homeDir, {}, {}, homeDir)), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(IHostFsWatchService, service), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + + try { + const source = host.app.accessor.get(IUserFileSkillSource); + await source.load(); + + const homeCalls = calls.filter((call) => call.path === homeDir); + expect(homeCalls).toHaveLength(1); + const ignored = homeCalls[0]?.ignored; + expect(ignored?.(join(homeDir, 'skills/demo/SKILL.md'))).toBe(false); + expect(ignored?.(join(homeDir, '.agents/skills/demo/SKILL.md'))).toBe(false); + expect(ignored?.(join(homeDir, 'sessions/s1/state.json'))).toBe(true); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it('does not watch the user skill roots when explicit skillDirs are set', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-explicit-')); + await mkdir(join(homeDir, 'skills'), { recursive: true }); + const { service, calls } = recordingWatchService(); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap(homeDir, {}, { skillDirs: ['/explicit'] })), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(IHostFsWatchService, service), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + + try { + const source = host.app.accessor.get(IUserFileSkillSource); + await source.load(); + expect(calls.map((call) => call.path)).toEqual([]); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + } + }); + + it('does not arm a user root watch when the base directory is missing', async () => { + const { service, calls } = recordingWatchService(); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair( + IBootstrapService, + stubBootstrap('/nonexistent-pythinker-home', {}, {}, '/nonexistent-pythinker-os-home'), + ), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(IHostFsWatchService, service), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + + try { + const source = host.app.accessor.get(IUserFileSkillSource); + await source.load(); + expect(calls.map((call) => call.path)).toEqual([]); + } finally { + host.dispose(); + } + }); + + it('disposes the user root watches when the app scope is disposed', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-dispose-')); + await mkdir(join(homeDir, 'skills'), { recursive: true }); + const { service, handles } = recordingWatchService(); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(IHostFsWatchService, service), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + ]); + + const source = host.app.accessor.get(IUserFileSkillSource); + await source.load(); + expect(handles.length).toBeGreaterThan(0); + + host.dispose(); + expect(handles.every((handle) => handle.disposed)).toBe(true); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('rescans the user source when skills appear, change and disappear under the user roots', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'skill-user-watch-')); + const osHomeDir = await mkdtemp(join(tmpdir(), 'skill-os-watch-')); + const host = createScopedTestHost([ + stubPair(IFlagService, stubFlag(true)), + stubPair(IBootstrapService, stubBootstrap(homeDir, {}, {}, osHomeDir)), + stubPair(IConfigService, configStub()), + stubPair(IPluginService, pluginStub()), + stubPair(ILogService, stubLog()), + stubPair(ISkillDiscovery, new FileSkillDiscovery(stubLog())), + stubPair(IHostFsWatchService, new HostFsWatchService()), + ]); + const workspace = host.child('program', 'w1', [ + stubPair(IWorkspaceContext, workspaceContextStub('/work')), + ]); + const writeSkill = (dir: string, description: string) => + writeFile( + join(dir, 'SKILL.md'), + `---\nname: watched-user-skill\ndescription: ${description}\n---\nbody`, + 'utf8', + ); + + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('watched-user-skill')).toBeUndefined(); + + const waitForUserChange = (): Promise => { + const refreshed = new Promise((resolvePromise) => { + const d = catalog.onDidChange((sourceId) => { + if (sourceId !== 'user') return; + d.dispose(); + resolvePromise(sourceId); + }); + }); + const timedOut = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('user watch refresh timed out')), 10000); + }); + return Promise.race([refreshed, timedOut]); + }; + + const created = waitForUserChange(); + const skillDir = join(homeDir, 'skills', 'watched-user-skill'); + await mkdir(skillDir, { recursive: true }); + await writeSkill(skillDir, 'v1'); + await created; + expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('v1'); + + const modified = waitForUserChange(); + await writeSkill(skillDir, 'v2'); + await modified; + expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('v2'); + + const deleted = waitForUserChange(); + await rm(skillDir, { recursive: true, force: true }); + await deleted; + expect(catalog.catalog.getSkill('watched-user-skill')).toBeUndefined(); + + const osCreated = waitForUserChange(); + const osSkillDir = join(osHomeDir, '.agents', 'skills', 'watched-user-skill'); + await mkdir(osSkillDir, { recursive: true }); + await writeSkill(osSkillDir, 'os'); + await osCreated; + expect(catalog.catalog.getSkill('watched-user-skill')?.description).toBe('os'); + } finally { + host.dispose(); + await rm(homeDir, { recursive: true, force: true }); + await rm(osHomeDir, { recursive: true, force: true }); + } + }, 20000); + }); diff --git a/packages/agent-core-v2/test/harness/snapshots.ts b/packages/agent-core-v2/test/harness/snapshots.ts index ef2a4cff1..6d5e12d75 100644 --- a/packages/agent-core-v2/test/harness/snapshots.ts +++ b/packages/agent-core-v2/test/harness/snapshots.ts @@ -356,6 +356,7 @@ function isVolatileDurationKey(key: string): boolean { key === 'llmServerFirstTokenMs' || key === 'llmServerDecodeMs' || key === 'llmClientConsumeMs' || + key === 'llmClientBlockedMs' || key === 'durationMs' ); } diff --git a/packages/agent-core/src/agent/replay/turns.ts b/packages/agent-core/src/agent/replay/turns.ts index fef7f941e..53c43693e 100644 --- a/packages/agent-core/src/agent/replay/turns.ts +++ b/packages/agent-core/src/agent/replay/turns.ts @@ -5,14 +5,17 @@ import type { AgentReplayRecord } from '../../rpc/resumed'; * * A record starts a new user turn when it is a user-role message that came * from an actual user action — a typed prompt, a user-invoked skill/plugin - * slash command, or a `!` shell command's input line. System-originated user - * messages (compaction summaries, cron fires, hook results, retries, goal - * reminders, background-task results, injections) continue the current turn - * instead — with one exception: `goal_continuation` prompts. The goal driver - * fires one synthetic continuation prompt per goal turn (see - * agent/turn/index.ts), and the goal system itself counts those as turns, so - * replay trimming treats them as turn boundaries; otherwise a 100-round goal - * would count as a single user turn and resume would replay the entire run. + * slash command, a `!` shell command's input line, or a cron delivery + * (`cron_job` / `cron_missed`). Scheduled fires are real rounds of work with + * their own prompts and reports; counting them keeps resume replay bounded for + * sessions dominated by many scheduled turns. Other system-originated user + * messages (compaction summaries, hook results, retries, goal reminders, + * background-task results, injections) continue the current turn instead — + * with one exception: `goal_continuation` prompts. The goal driver fires one + * synthetic continuation prompt per goal turn (see agent/turn/index.ts), and + * the goal system itself counts those as turns, so replay trimming treats them + * as turn boundaries; otherwise a 100-round goal would count as a single user + * turn and resume would replay the entire run. * * Source of truth for turn-boundary detection; the TUI mirrors this through * the SDK re-export instead of keeping its own predicate. @@ -32,10 +35,11 @@ export function isAgentReplayUserTurnRecord(record: AgentReplayRecord): boolean case 'shell_command': // A `!` command's input is a user-turn anchor; its output is not. return message.origin.phase === 'input'; - case 'background_task': - case 'compaction_summary': case 'cron_job': case 'cron_missed': + return true; + case 'background_task': + case 'compaction_summary': case 'hook_result': case 'injection': case 'retry': diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index fccce42f7..7ca051f83 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -1764,4 +1764,39 @@ describe('limitAgentReplayByTurns', () => { // trailing reminder stays attached to the last kept turn. expect(limited).toEqual(records.slice(11)); }); + + it('treats cron deliveries as turn boundaries so scheduled storms stay bounded', () => { + const records: AgentReplayRecord[] = []; + for (let turn = 0; turn < 20; turn += 1) { + records.push( + replayMessage('user', `cron fire ${turn}`, { + kind: 'cron_job', + jobId: 'job-1', + cron: '*/15 * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }), + ); + records.push(replayMessage('assistant', `report ${turn}`)); + } + const limited = limitAgentReplayByTurns(records, 5); + expect(limited).toHaveLength(10); + expect(JSON.stringify(limited)).toContain('cron fire 15'); + expect(JSON.stringify(limited)).toContain('cron fire 19'); + expect(JSON.stringify(limited)).not.toContain('cron fire 14'); + }); + + it('treats cron missed deliveries as turn boundaries', () => { + const records: AgentReplayRecord[] = []; + for (let turn = 0; turn < 20; turn += 1) { + records.push(replayMessage('user', `missed ${turn}`, { kind: 'cron_missed', count: 3 })); + records.push(replayMessage('assistant', `report ${turn}`)); + } + const limited = limitAgentReplayByTurns(records, 5); + expect(limited).toHaveLength(10); + expect(JSON.stringify(limited)).toContain('missed 15'); + expect(JSON.stringify(limited)).toContain('missed 19'); + expect(JSON.stringify(limited)).not.toContain('missed 14'); + }); }); diff --git a/packages/kosong/src/providers/anthropic-profile.ts b/packages/kosong/src/providers/anthropic-profile.ts index e9395304b..e129d8dc6 100644 --- a/packages/kosong/src/providers/anthropic-profile.ts +++ b/packages/kosong/src/providers/anthropic-profile.ts @@ -74,9 +74,10 @@ const VERSION_FIRST_RE = /(\d{1,2})[-._](\d{1,2})[-._](opus|sonnet|haiku)/; const BARE_FAMILY_RE = /(\d{1,2})[-._](opus|sonnet|haiku)/; export function parseAnthropicModelVersion( - model: string, + model: string | undefined, requireClaudeMarker = false, ): AnthropicModelVersion | null { + if (model === undefined) return null; const normalized = model.toLowerCase(); if (requireClaudeMarker && !normalized.includes('claude')) return null; @@ -111,8 +112,9 @@ export function parseAnthropicModelVersion( } export function matchKnownAnthropicModelProfile( - model: string, + model: string | undefined, ): AnthropicModelProfile | undefined { + if (model === undefined) return undefined; const normalized = model.toLowerCase(); if (/mythos[-._]preview/.test(normalized)) return ALWAYS_ADAPTIVE_MAX_PROFILE; @@ -164,7 +166,10 @@ export function inferAnthropicModelProfile(model: string): AnthropicModelProfile * fallback: an Anthropic-protocol endpoint still needs some profile to shape * requests. */ -export function matchUnknownClaudeProfile(model: string): AnthropicModelProfile | undefined { +export function matchUnknownClaudeProfile( + model: string | undefined, +): AnthropicModelProfile | undefined { + if (model === undefined) return undefined; const normalized = model.toLowerCase(); return normalized.includes('claude') || CLAUDE_FAMILY_WORD_RE.test(normalized) ? LATEST_OPUS_PROFILE diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 6cbc1f04c..34e52cd7b 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -7,7 +7,12 @@ import { ChatProviderError } from '#/errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider, resolveDefaultMaxTokens } from '#/providers/anthropic'; -import { matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, LATEST_OPUS_PROFILE } from '#/providers/anthropic-profile'; +import { + LATEST_OPUS_PROFILE, + matchKnownAnthropicModelProfile, + matchUnknownClaudeProfile, + parseAnthropicModelVersion, +} from '#/providers/anthropic-profile'; import type { GenerateOptions } from '#/provider'; import type { Tool } from '#/tool'; import { describe, it, expect, vi } from 'vitest'; @@ -98,6 +103,13 @@ describe('Anthropic model profile matching', () => { expect(matchUnknownClaudeProfile(model)).toBeUndefined(); }, ); + + it('tolerates an undefined model name from a malformed config entry', () => { + expect(matchKnownAnthropicModelProfile(undefined)).toBeUndefined(); + expect(matchUnknownClaudeProfile(undefined)).toBeUndefined(); + expect(parseAnthropicModelVersion(undefined)).toBeNull(); + }); + }); type AnthropicGenerationState = { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index a43020efd..1b3a5ea3c 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -148,7 +148,11 @@ import { } from '@pymodel/agent-core'; import { encodeWorkDirKey } from '@pymodel/agent-core-v2/_base/utils/workdir-slug'; import { McpConnectionManager } from '@pymodel/agent-core-v2/mcpCore/connection-manager'; -import { loadMcpServers } from '@pymodel/agent-core-v2/app/mcpConfig/configLoader'; +import { + loadMcpServers, + loadMcpServersDetailed, + resolveMcpJsonPaths, +} from '@pymodel/agent-core-v2/app/mcpConfig/configLoader'; import { IAppendLogStore } from '@pymodel/agent-core-v2/persistence/interface/appendLogStore'; import type { McpServerConfig as WorkspaceMcpServerConfig } from '@pymodel/agent-core-v2/mcpCore/config-schema'; import { @@ -629,8 +633,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * via {@link engineAccessor} — the same `handlerFor({ root })` path * `createSession` takes (materializing the workspace handler is a no-op * cost here: session creation does it anyway). The gated-server list is - * what the pure config loader sees with project files included vs skipped - * (the workspaceTrust gate inside the engine's `workspaceMcpConfig`), + * the final merged config entries whose origins are project files (the + * workspaceTrust gate inside the engine's `workspaceMcpConfig`), * computed best-effort: an unreadable/invalid project file degrades to an * empty list rather than failing the caller. */ @@ -642,12 +646,18 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (trusted) return { trusted: true, gatedMcpServers: [] }; try { const fs = this.engineAccessor.get(IHostFileSystem); - const [withProject, userOnly] = await Promise.all([ - loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: true }), - loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: false }), + const [paths, loaded] = await Promise.all([ + resolveMcpJsonPaths({ fs, cwd: workDir, homeDir: this.homeDir }), + loadMcpServersDetailed({ + fs, + cwd: workDir, + homeDir: this.homeDir, + includeProject: true, + }), ]); - const gatedMcpServers = Object.entries(withProject) - .filter(([name]) => !(name in userOnly)) + const projectPaths = new Set([paths.projectRoot, paths.project]); + const gatedMcpServers = Object.entries(loaded.servers) + .filter(([name]) => projectPaths.has(loaded.origins[name] ?? '')) .map(([name, config]) => describeWorkspaceMcpServer(name, config)) .toSorted((a, b) => a.name.localeCompare(b.name)); return { trusted: false, gatedMcpServers }; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 09c739d14..8a4fc3f84 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -977,6 +977,48 @@ describe('SDKRpcClientV2 workspace trust', () => { await harness.close(); } }); + + it('reports project servers that override same-named user entries', async () => { + const { harness, homeDir } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'pythinker-sdk-v2-work-')); + tempDirs.push(workDir); + await writeFile( + join(homeDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + github: { command: 'user-github', enabled: false }, + }, + }), + 'utf-8', + ); + await writeFile( + join(workDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + github: { command: 'project-github', enabled: false }, + toString: { transport: 'http', url: 'https://example.test/mcp', enabled: false }, + }, + }), + 'utf-8', + ); + try { + const info = await harness.getWorkspaceTrustInfo(workDir); + expect(info.trusted).toBe(false); + expect(info.gatedMcpServers).toEqual([ + { + name: 'github', + transport: 'stdio', + command: 'project-github', + args: undefined, + cwd: workDir, + }, + { name: 'toString', transport: 'http', url: 'https://example.test/mcp' }, + ]); + } finally { + await harness.close(); + } + }); + }); describe('foldAgentWireReplay', () => {