From 79383ea68f3328c6056dd6b9ffa74df83c24c9aa Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 7 Sep 2026 01:59:30 -0400 Subject: [PATCH 1/5] fix(agent): rebuild persisted subagents when resuming after a restart --- .changeset/resume-subagent-after-restart.md | 5 + .../src/agent/tools/agent/agentTool.ts | 63 +++-- .../agent-core-v2/src/features/tower/tower.ts | 4 + .../agentLifecycle/agentLifecycleService.ts | 19 +- .../agentLifecycle/subagentMetadata.ts | 13 ++ .../agentLifecycle/agentLifecycle.test.ts | 50 ++++ packages/agent-core-v2/test/tool/tool.test.ts | 215 ++++++++++++++++++ 7 files changed, 342 insertions(+), 27 deletions(-) create mode 100644 .changeset/resume-subagent-after-restart.md diff --git a/.changeset/resume-subagent-after-restart.md b/.changeset/resume-subagent-after-restart.md new file mode 100644 index 000000000..96cb695a9 --- /dev/null +++ b/.changeset/resume-subagent-after-restart.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Resuming a subagent by its agent id works again after the session is reopened in a new process. diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index e5b2bf93b..18e060112 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -22,6 +22,7 @@ import { import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { ToolAccesses, type ExecutableToolContext, @@ -44,8 +45,15 @@ import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; +import { + isSubagentMeta, + labelsFromAgentMeta, + subagentLabels, + subagentParentAgentId, + subagentProfileName, +} from '#/session/agentLifecycle/subagentMetadata'; +import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -108,6 +116,7 @@ export class SubagentTool implements ISubagentTool { @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, + @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @ILogService private readonly log: ILogService, @IConfigService private readonly config: IConfigService, @@ -232,7 +241,7 @@ export class SubagentTool implements ISubagentTool { const profileNameForDisplay = resumeAgentId !== undefined && resumeAgentId.length > 0 - ? this.resumeProfileName(resumeAgentId) ?? RESUMED_LABEL + ? (await this.resumeProfileName(resumeAgentId)) ?? RESUMED_LABEL : (requestedProfileName ?? (args.fork === true ? (this.profile.data().profileName ?? DEFAULT_PROFILE_NAME) @@ -253,10 +262,10 @@ export class SubagentTool implements ISubagentTool { }; } - private resumeProfileName(agentId: string): string | undefined { + private async resumeProfileName(agentId: string): Promise { const target = this.agentLifecycle.handleOf(agentId); - if (target === undefined) return undefined; - return target.accessor.get(IAgentProfileService).data().profileName; + if (target !== undefined) return target.accessor.get(IAgentProfileService).data().profileName; + return subagentProfileName((await this.sessionMetadata.read()).agents?.[agentId]); } private async launch( @@ -283,13 +292,7 @@ export class SubagentTool implements ISubagentTool { let currentRoutingEnvironmentRevision: string | undefined; let promptText = args.prompt; if (isResume) { - const target = this.agentLifecycle.handleOf(resumeAgentId); - if (target === undefined) { - throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, { - details: { agentId: resumeAgentId }, - }); - } - await this.ensureOwnedIdleSubagent(resumeAgentId, target); + const target = await this.resolveResumeTarget(resumeAgentId); agentId = target.id; const resumed = target.accessor.get(IAgentProfileService).data(); profileName = resumed.profileName ?? RESUMED_LABEL; @@ -349,12 +352,15 @@ export class SubagentTool implements ISubagentTool { }; } - private async ensureOwnedIdleSubagent( - agentId: string, - target: IAgentScopeHandle, - ): Promise { + private async resolveResumeTarget(agentId: string): Promise { + const live = this.agentLifecycle.handleOf(agentId); const meta = (await this.sessionMetadata.read()).agents?.[agentId]; - if (!isSubagentMeta(meta)) { + if (meta === undefined && live === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (meta === undefined || !isSubagentMeta(meta)) { throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, { details: { agentId }, }); @@ -366,6 +372,7 @@ export class SubagentTool implements ISubagentTool { { details: { agentId, callerAgentId: this.callerAgentId } }, ); } + const target = live ?? (await this.rebuildSubagent(agentId, meta)); if (target.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error2( ErrorCodes.AGENT_ALREADY_RUNNING, @@ -373,6 +380,26 @@ export class SubagentTool implements ISubagentTool { { details: { agentId } }, ); } + return target; + } + + private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise { + await this.agentLifecycle.create({ + agentId, + labels: labelsFromAgentMeta(meta), + forkedFrom: meta.forkedFrom, + }); + const rebuilt = this.agentLifecycle.handleOf(agentId); + if (rebuilt === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, { + details: { agentId }, + }); + } + if (!hasPinnedPermissionMode(rebuilt.accessor.get(IAgentProfileService).data().profileName)) { + rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode); + } + this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId }); + return rebuilt; } private async execution( diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts index a9fd8acf7..eef689818 100644 --- a/packages/agent-core-v2/src/features/tower/tower.ts +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -15,6 +15,10 @@ export const TOWER_TOOL_NAMES = [ export const TOWER_WORKER_PROFILE = 'tower-worker'; +export function hasPinnedPermissionMode(profileName: string | undefined): boolean { + return profileName === TOWER_WORKER_PROFILE; +} + export const TOWER_FLAG_ID = 'tower'; export interface IAgentTowerService { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 8a58f9686..7b8b254a0 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -20,10 +20,11 @@ import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSe import { permissionModeConfiguredKey } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { profileKey } from '#/agent/profile/profileOps'; -import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; +import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { withSubagentProfile } from '#/session/agentLifecycle/subagentMetadata'; import { agentContextOf, IAgentScopeContext, @@ -266,7 +267,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle type: agentId === 'main' ? 'main' : 'sub', parentAgentId: agentId === 'main' ? undefined : 'main', forkedFrom: opts.forkedFrom, - labels: opts.labels, + labels: withSubagentProfile( + opts.labels, + agentId === 'main' ? undefined : opts.binding?.profile, + ), }); this.onDidCreateEmitter.fire(agent); didCreate = true; @@ -332,17 +336,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }); } const source = sourceManaged.handle; + const sourceData = source.accessor.get(IAgentProfileService).data(); + const override = opts?.binding; const childContext = await this.create({ agentId: opts?.agentId, runtimeId: source.accessor.get(IAgentRuntimeBindingService).current.runtimeId, forkedFrom: source.id, - labels: opts?.labels, + labels: withSubagentProfile(opts?.labels, override?.profile ?? sourceData.profileName), }); const child = this.requireManaged(childContext).handle; - const sourceData = source.accessor.get(IAgentProfileService).data(); const childProfile = child.accessor.get(IAgentProfileService); - const override = opts?.binding; if (override?.profile !== undefined) { await childProfile.bind({ profile: override.profile, @@ -402,10 +406,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle for (const managed of this.roster.values()) { if (managed.closing || !managed.active) continue; const handle = managed.handle; - if ( - handle.accessor.get(IAgentStateService).get(profileKey).profileName === - TOWER_WORKER_PROFILE - ) { + if (hasPinnedPermissionMode(handle.accessor.get(IAgentStateService).get(profileKey).profileName)) { continue; } handle.accessor.get(IAgentPermissionModeService).setMode(mode); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts index ef3dd400e..8690c4c4d 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts @@ -26,6 +26,14 @@ export function labelsFromAgentMeta( return Object.keys(labels).length > 0 ? labels : undefined; } +export function withSubagentProfile( + labels: Readonly> | undefined, + profileName: string | undefined, +): Readonly> | undefined { + if (profileName === undefined || profileName.length === 0) return labels; + return { ...labels, profileName }; +} + export function isSubagentMeta(meta: AgentMeta | undefined): boolean { if (meta === undefined) return false; if (subagentParentAgentId(meta) !== undefined) return true; @@ -42,6 +50,11 @@ export function subagentDynamicWorkflowItem(meta: AgentMeta | undefined): string return firstNonEmpty(meta.labels?.['dynamicWorkflowItem'], meta.dynamicWorkflowItem); } +export function subagentProfileName(meta: AgentMeta | undefined): string | undefined { + if (meta === undefined) return undefined; + return firstNonEmpty(meta.labels?.['profileName']); +} + function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined { return values.find((value) => value !== undefined && value.length > 0); } diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index b017184dd..9b669e15b 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -1315,6 +1315,56 @@ describe('AgentLifecycleService', () => { ); }); + it('records the bound profile in the subagent metadata at registration', async () => { + const svc = ix.get(IAgentLifecycleService); + + await svc + .create({ + agentId: 'child', + binding: { profile: 'coder', model: 'test-model' }, + labels: { parentAgentId: 'main' }, + }) + .catch(() => undefined); + + expect(registerAgent).toHaveBeenCalledWith( + 'child', + expect.objectContaining({ + type: 'sub', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + }); + + it('fork records the inherited profile in the subagent metadata', async () => { + const svc = ix.get(IAgentLifecycleService); + const source = await svc.create({ agentId: 'main' }); + svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({ + profileName: 'coder', + thinkingLevel: 'off', + systemPrompt: 'coder prompt', + activeToolNames: ['Read'], + disallowedTools: [], + subagents: undefined, + }); + + await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), { + agentId: 'forked', + labels: { parentAgentId: 'main' }, + }); + + expect(registerAgent).toHaveBeenCalledWith( + 'forked', + expect.objectContaining({ + forkedFrom: 'main', + labels: { parentAgentId: 'main', profileName: 'coder' }, + }), + ); + expect(registerAgent).toHaveBeenCalledWith( + 'main', + expect.objectContaining({ type: 'main', labels: undefined }), + ); + }); + it('run throws when the agent does not exist', () => { ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService)); const svc = ix.get(ISessionSubagentService); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 1030f9f12..7fe43f6e4 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -100,6 +100,7 @@ import { } from '../harness'; import { executeTool } from '../tools/fixtures/execute-tool'; import { stubAgentContext } from '../agent/agentContext/stubs'; +import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { ManagedAgent } from '#/session/agentLifecycle/managedAgent'; import { AgentTodo, todoAgentRuntimeProvider } from '#/features/todo/todoAgentRuntime'; import { AgentInteraction, interactionAgentRuntimeProvider } from '#/features/interaction/interactionAgentRuntime'; @@ -1458,6 +1459,48 @@ describe('Agent tool execution contract', () => { expect(lifecycle.list).toHaveBeenCalled(); }); + it('uses the persisted profile of an offline subagent for display and approval rules', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: 'explore' } }, + }), + ), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching explore agent: Continue work'); + expect(execution.matchesRule?.('explore')).toBe(true); + expect(execution.matchesRule?.('coder')).toBe(false); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('falls back to the generic label when an offline subagent has no persisted profile', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + + const execution = await agentTool(context).resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching subagent agent: Continue work'); + }); + it('labels fork launches with the caller profile for display and approval rules', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext(lifecycle, forkFlags()); @@ -2179,6 +2222,178 @@ describe('Agent tool execution contract', () => { expect(result.output).toContain('resumed result'); }); + it('rebuilds a persisted subagent that is not live before resuming it', async () => { + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { + type: 'sub', + parentAgentId: 'main', + forkedFrom: 'main', + labels: { parentAgentId: 'main' }, + }, + }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(lifecycle.create).toHaveBeenCalledTimes(1); + expect(lifecycle.create).toHaveBeenCalledWith({ + agentId: 'agent-existing', + labels: { parentAgentId: 'main' }, + forkedFrom: 'main', + }); + expect(lifecycle.run).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-existing' }), + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.isError).not.toBe(true); + expect(result.output).toContain('agent_id: agent-existing'); + expect(result.output).toContain('resumed after restart'); + }); + + it('keeps rejecting resume of an agent id that was never persisted', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({})), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-missing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-missing" does not exist', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('does not rebuild a persisted subagent owned by another parent', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta('other') }), + ), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: Agent instance "agent-existing" does not belong to this parent agent', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('syncs a rebuilt subagent to the caller permission mode before resuming it', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed after restart' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'yolo', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService(ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() })), + ); + context.get(IAgentPermissionModeService).setMode('auto'); + expect(context.get(IAgentPermissionModeService).mode).toBe('auto'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).not.toBe(true); + expect(setMode).toHaveBeenCalledWith('auto'); + expect(setMode.mock.invocationCallOrder[0]).toBeLessThan( + lifecycle.run.mock.invocationCallOrder[0]!, + ); + }); + + it('keeps a rebuilt tower worker on its pinned permission mode', async () => { + const setMode = vi.fn(); + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'worker resumed' }), + handleServices: new Map>([ + [ + 'agent-existing', + new Map([ + [ + IAgentProfileService, + { + _serviceBrand: undefined, + data: () => ({ profileName: TOWER_WORKER_PROFILE }), + update: () => {}, + republishStatus: () => {}, + getEffectiveThinkingLevel: () => 'off', + getActiveToolNames: () => [], + isToolActive: () => false, + }, + ], + [ + IAgentPermissionModeService, + { _serviceBrand: undefined, mode: 'auto', setMode, onDidChangeMode: Event.None }, + ], + ]), + ], + ]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ + 'agent-existing': { labels: { parentAgentId: 'main', profileName: TOWER_WORKER_PROFILE } }, + }), + ), + ); + context.get(IAgentPermissionModeService).setMode('manual'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result).toEqual({ output: expect.stringContaining(`actual_subagent_type: ${TOWER_WORKER_PROFILE}`) }); + expect(setMode).not.toHaveBeenCalled(); + expect(lifecycle.run).toHaveBeenCalledOnce(); + }); + it('rejects direct resume of a non-subagent', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( From 56fb7d3ac945325926a5e119c58c5d5d6fa2e70a Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 7 Sep 2026 01:59:39 -0400 Subject: [PATCH 2/5] fix(agent): write the bound model into the ambient telemetry context --- .changeset/telemetry-model-context.md | 5 ++ .../src/agent/profile/profileService.ts | 24 ++++-- .../app/telemetry/agentTelemetryContext.ts | 1 + .../test/agent/loop/loop.test.ts | 2 + .../test/agent/profile/config-state.test.ts | 80 +++++++++++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 .changeset/telemetry-model-context.md diff --git a/.changeset/telemetry-model-context.md b/.changeset/telemetry-model-context.md new file mode 100644 index 000000000..8402b8e4d --- /dev/null +++ b/.changeset/telemetry-model-context.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Include the bound model in telemetry event context. diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 3184704a4..9a9561224 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -174,6 +174,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.states.contributeState(profileEmittedToolPatternWarningsKey); this.states.contributeState(profileEmittedPluginBudgetWarningsKey); this.configure({}); + this._register( + this.dispatcher.hooks.onDidRestore.register('profile', async (_ctx, next) => { + this.syncTelemetryModelContext(this.modelAlias); + await next(); + }), + ); this._register( this.config.onDidSectionChange(({ domain }) => { if (domain === TOOLS_SECTION) { @@ -545,11 +551,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private afterConfigDispatch(changed: Omit): void { if (changed.modelAlias !== undefined) { - const model = this.tryResolveRawModel(); - this.telemetryContext.set({ - provider_type: model?.providerType ?? model?.protocol, - protocol: model?.protocol, - }); + this.syncTelemetryModelContext(changed.modelAlias); } if (changed.modelAlias !== undefined || changed.thinkingLevel !== undefined) { this.warnAboutAnthropicThinkingEffort(); @@ -559,6 +561,18 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } + private syncTelemetryModelContext(modelAlias: string | undefined): void { + if (modelAlias === undefined) { + return; + } + const model = this.tryResolveRawModel(); + this.telemetryContext.set({ + model: modelAlias, + provider_type: model?.providerType ?? model?.protocol, + protocol: model?.protocol, + }); + } + private warnAboutAnthropicThinkingEffort(): void { try { const model = this.tryResolveRawModel(); diff --git a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts index 7e298e0b3..c06745cfb 100644 --- a/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts +++ b/packages/agent-core-v2/src/app/telemetry/agentTelemetryContext.ts @@ -2,6 +2,7 @@ import { createDecorator } from '#/_base/di/instantiation'; export type AgentTelemetryContext = { mode: 'agent' | 'plan'; + model?: string; provider_type?: string; protocol?: string; turn_id?: number; 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 c47990582..e732cbbbb 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -996,6 +996,7 @@ describe('turn telemetry', () => { turn_id: 0, agent_id: 'main', mode: 'agent', + model: 'mock-model', provider_type: 'pythinker', protocol: 'openai', thinking_effort: 'off', @@ -1008,6 +1009,7 @@ describe('turn telemetry', () => { reason: 'completed', duration_ms: expect.any(Number), mode: 'agent', + model: 'mock-model', provider_type: 'pythinker', protocol: 'openai', thinking_effort: 'off', diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index e8a099d7c..f74ff04b6 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -3,13 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { ModelRecord } from '#/kosong/model/model'; import { configServices, createTestAgent, + InMemoryWireRecordPersistence, llmGenerateServices, modelProviderOptionServices, telemetryServices, + wireRecordPersistenceServices, type TestAgentContext, } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; @@ -160,6 +163,83 @@ describe('ConfigState model capabilities', () => { }); }); + it('writes the bound model into the ambient telemetry context', () => { + pythinkerConfig = { + providers: { + pythinker: { + type: 'pythinker', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + }, + }, + models: { + 'example/test-model': { + provider: 'pythinker', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + }, + }, + }; + + profile.update({ modelAlias: 'example/test-model' }); + + expect(ctx.get(IAgentTelemetryContextService).get()).toMatchObject({ + model: 'example/test-model', + provider_type: 'pythinker', + protocol: 'openai', + }); + }); + + it('keeps the alias as ambient model when the bound model does not resolve', () => { + profile.update({ modelAlias: 'ghost/model' }); + + expect(ctx.get(IAgentTelemetryContextService).get()).toMatchObject({ + model: 'ghost/model', + }); + }); + + it('restores the ambient model after a cold resume', async () => { + pythinkerConfig = { + providers: { + pythinker: { + type: 'pythinker', + apiKey: 'test-key', + baseUrl: 'https://api.example.test/v1', + }, + }, + models: { + 'example/test-model': { + provider: 'pythinker', + model: 'kimi-for-coding', + maxContextSize: 1_000_000, + }, + }, + }; + const resumedRecords: TelemetryRecord[] = []; + const resumed = createTestAgent( + { autoConfigure: false }, + configServices(() => pythinkerConfig), + llmGenerateServices((...args) => generate(...args)), + telemetryServices(recordingTelemetry(resumedRecords)), + wireRecordPersistenceServices( + new InMemoryWireRecordPersistence([ + { type: 'config.update', agentId: 'main', modelAlias: 'example/test-model' }, + ]), + ), + ); + try { + await resumed.restorePersisted(); + + expect(resumed.get(IAgentTelemetryContextService).get()).toMatchObject({ + model: 'example/test-model', + provider_type: 'pythinker', + protocol: 'openai', + }); + } finally { + await resumed.dispose(); + } + }); + it('does not infer Pythinker capabilities from the provider catalogue', () => { pythinkerConfig = { providers: { From 22fee2c5002b1f93861b3e5d9dfec3f1506b58cc Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 7 Sep 2026 01:59:39 -0400 Subject: [PATCH 3/5] fix(gateway): stop event journal writes after server close --- .../ws/v1/sessionEventBroadcaster.ts | 15 +++++++++--- .../transport/ws/v1/sessionEventJournal.ts | 3 +++ .../test/sessionEventBroadcaster.test.ts | 23 ++++++++++++++++++- .../test/sessionEventJournal.test.ts | 3 +++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts index 706bf3a85..9ef6c62e1 100644 --- a/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/agent-gateway/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -534,6 +534,9 @@ export class SessionEventBroadcaster { if (this.closed) return; this.closed = true; this.coreEventSubscription.dispose(); + await Promise.all( + [...this.pendingStates.values()].map((pending) => pending.catch(() => undefined)), + ); for (const [sessionId, state] of this.sessions) { await disposeSessionState(state); this.opts.transcriptService?.dropSession(sessionId); @@ -602,7 +605,8 @@ export class SessionEventBroadcaster { return state; } - private ensureGlobalState(): Promise { + private ensureGlobalState(): Promise { + if (this.closed) return Promise.resolve(undefined); const existing = this.sessions.get(GLOBAL_SESSION_ID); if (existing !== undefined) return Promise.resolve(existing); let pending = this.pendingStates.get(GLOBAL_SESSION_ID); @@ -614,15 +618,19 @@ export class SessionEventBroadcaster { }); this.pendingStates.set(GLOBAL_SESSION_ID, pending); } - return pending as Promise; + return pending; } - private async createGlobalState(): Promise { + private async createGlobalState(): Promise { const journal = await SessionEventJournal.open( this.opts.eventsDir, GLOBAL_SESSION_ID, this.opts.logger, ); + if (this.closed) { + await journal.close(); + return undefined; + } const state: SessionState = { sessionId: GLOBAL_SESSION_ID, journal, @@ -816,6 +824,7 @@ export class SessionEventBroadcaster { private async dispatchGlobal(event: Event): Promise { const state = await this.ensureGlobalState(); + if (state === undefined) return; state.queue = state.queue .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); diff --git a/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts b/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts index 34d2ef323..d8e40ebed 100644 --- a/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/agent-gateway/src/transport/ws/v1/sessionEventJournal.ts @@ -47,6 +47,7 @@ export class SessionEventJournal { private _seq: number; private pendingLines: string[] = []; private flushPromise: Promise | undefined; + private closed = false; private headerPending: boolean; private constructor( @@ -110,6 +111,7 @@ export class SessionEventJournal { } append(seq: number, envelope: EventEnvelope): void { + if (this.closed) return; const line: JournalEventLine = { kind: 'event', seq, envelope }; this.pendingLines.push(JSON.stringify(line)); this.scheduleFlush(); @@ -145,6 +147,7 @@ export class SessionEventJournal { } async close(): Promise { + this.closed = true; await this.flush(); } diff --git a/packages/agent-gateway/test/sessionEventBroadcaster.test.ts b/packages/agent-gateway/test/sessionEventBroadcaster.test.ts index 3dc29295d..7b17d97f8 100644 --- a/packages/agent-gateway/test/sessionEventBroadcaster.test.ts +++ b/packages/agent-gateway/test/sessionEventBroadcaster.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -1381,6 +1381,19 @@ describe('SessionEventBroadcaster', () => { root: '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/repo/a', }, }); + + const journalPath = join(dir, '__global__.jsonl'); + await vi.waitFor(async () => { + expect(await readFile(journalPath, 'utf8').catch(() => '')).toContain('wd_a'); + }); + const before = await readFile(journalPath, 'utf8'); + eventBus.emit({ + type: 'event.workspace.deleted', + payload: { workspaceId: 'wd_b', root: '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/repo/b' }, + }); + await bc.close(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(await readFile(journalPath, 'utf8')).toBe(before); }); it('gates event.di.unit_changed to connections opted into the DI debug feed', async () => { @@ -2306,6 +2319,14 @@ describe('SessionEventBroadcaster', () => { delta: (envelope.payload as { delta: string }).delta, })), ).toEqual([{ offset: 0, delta: 'abc' }]); + + eventBus.emit({ + type: 'event.workspace.deleted', + payload: { workspaceId: 'wd_late', root: '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/repo/late' }, + }); + await bc.close(); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(await readdir(dir)).not.toContain('__global__.jsonl'); }); describe('transcript streaming', () => { diff --git a/packages/agent-gateway/test/sessionEventJournal.test.ts b/packages/agent-gateway/test/sessionEventJournal.test.ts index d3d70532d..a9cc9c3de 100644 --- a/packages/agent-gateway/test/sessionEventJournal.test.ts +++ b/packages/agent-gateway/test/sessionEventJournal.test.ts @@ -53,6 +53,9 @@ describe('SessionEventJournal', () => { j1.append(j1.nextSeq(), envelope(2)); await j1.close(); + j1.append(j1.nextSeq(), envelope(3)); + await j1.flush(); + const j2 = await SessionEventJournal.open(dir, 'sess_1'); expect(j2.epoch).toBe(epoch); expect(j2.seq).toBe(2); From 1a44db4ed75e796430a4c3268f999c16ae0de074 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 7 Sep 2026 01:59:40 -0400 Subject: [PATCH 4/5] fix(cli): open localhost instead of the wildcard bind address when auto-opening the web UI --- .changeset/web-open-localhost.md | 5 ++ .../src/cli/sub/web/access-urls.ts | 12 ++++ apps/pythinker-code/src/cli/sub/web/run.ts | 6 +- apps/pythinker-code/test/cli/web/web.test.ts | 55 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .changeset/web-open-localhost.md diff --git a/.changeset/web-open-localhost.md b/.changeset/web-open-localhost.md new file mode 100644 index 000000000..7879eef04 --- /dev/null +++ b/.changeset/web-open-localhost.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Open the browser on localhost instead of the wildcard bind address when auto-opening the web UI. diff --git a/apps/pythinker-code/src/cli/sub/web/access-urls.ts b/apps/pythinker-code/src/cli/sub/web/access-urls.ts index 1875d4185..67090dbbd 100644 --- a/apps/pythinker-code/src/cli/sub/web/access-urls.ts +++ b/apps/pythinker-code/src/cli/sub/web/access-urls.ts @@ -41,6 +41,18 @@ function isWildcard(host: string): boolean { return host === '' || host === '0.0.0.0' || host === '::'; } +/** + * Rewrite a bound origin for browser auto-open. A wildcard bind host + * (`0.0.0.0` / `::` / empty) is not navigable, so open localhost on the same + * port instead — the same address the ready banner's `Local:` line shows. + */ +export function browserOpenOrigin(origin: string): string { + const separator = origin.lastIndexOf(':'); + const host = origin.slice(origin.indexOf('://') + 3, separator); + if (!isWildcard(host)) return origin; + return `http://localhost${origin.slice(separator)}`; +} + /** True when `host` is a loopback address (this host only). */ export function isLoopbackHost(host: string): boolean { return host === 'localhost' || host === '127.0.0.1' || host === '::1'; diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 6ebc71073..0a0ce27b4 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -35,6 +35,7 @@ import { } from '../../version'; import { accessUrlLines, + browserOpenOrigin, buildOpenableUrl, isLoopbackHost, splitTokenFragment, @@ -289,7 +290,8 @@ export async function handleWebCommand( : formatReadyLine(origin, token, parsed.dangerousBypassAuth), ); if (opts.open === true) { - deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + const openOrigin = browserOpenOrigin(origin); + deps.openUrl(token !== undefined ? buildWebUrl(openOrigin, token) : openOrigin); } }, onShutdown: async () => { @@ -517,7 +519,7 @@ export function formatReadyBanner( return frag === '' ? url(base) : url(base) + dim(frag); }; - const port = Number(new URL(origin).port); + const port = Number(origin.slice(origin.lastIndexOf(':') + 1)); const lines: string[] = opts.useTuiLogo === true ? [ diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 89e96be7f..093e4901a 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -381,6 +381,46 @@ describe('`pythinker web` opens the browser', () => { expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); }); + it('opens localhost rather than the wildcard bind address', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://0.0.0.0:58627'); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { host: '0.0.0.0', open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://localhost:58627/#token=tok-xyz'); + }); + + it('opens localhost for a wildcard IPv6 bind', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://:::58627'); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { host: '::', open: true }, + { + startServerForeground: runner, + resolveToken: () => undefined, + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://localhost:58627'); + }); + it('does not open the browser when open is false', async () => { const { handleWebCommand } = await import('#/cli/sub/web/run'); const { runner } = makeRunner('http://127.0.0.1:9000'); @@ -1034,6 +1074,21 @@ describe('accessUrlLines', () => { }); }); +describe('browserOpenOrigin', () => { + it('rewrites wildcard bind hosts to localhost on the same port', async () => { + const { browserOpenOrigin } = await import('#/cli/sub/web/access-urls'); + expect(browserOpenOrigin('http://0.0.0.0:58627')).toBe('http://localhost:58627'); + expect(browserOpenOrigin('http://:::58627')).toBe('http://localhost:58627'); + }); + + it('keeps navigable origins unchanged', async () => { + const { browserOpenOrigin } = await import('#/cli/sub/web/access-urls'); + expect(browserOpenOrigin('http://127.0.0.1:58627')).toBe('http://127.0.0.1:58627'); + expect(browserOpenOrigin('http://192.168.1.5:58627')).toBe('http://192.168.1.5:58627'); + expect(browserOpenOrigin('http://[::1]:58627')).toBe('http://[::1]:58627'); + }); +}); + describe('`pythinker web rotate-token`', () => { let dir: string; let prevHome: string | undefined; From b3d18ad61503bdce26240e01e1ccf2e82a555eab Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 7 Sep 2026 06:06:29 -0400 Subject: [PATCH 5/5] fix(cli): bracket IPv6 hosts in the reported server origin and auto-open --- .../src/cli/sub/web/access-urls.ts | 7 +++--- apps/pythinker-code/src/cli/sub/web/run.ts | 4 ++-- apps/pythinker-code/test/cli/web/web.test.ts | 23 ++++++++++++++++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/pythinker-code/src/cli/sub/web/access-urls.ts b/apps/pythinker-code/src/cli/sub/web/access-urls.ts index 67090dbbd..844f3de3e 100644 --- a/apps/pythinker-code/src/cli/sub/web/access-urls.ts +++ b/apps/pythinker-code/src/cli/sub/web/access-urls.ts @@ -43,13 +43,14 @@ function isWildcard(host: string): boolean { /** * Rewrite a bound origin for browser auto-open. A wildcard bind host - * (`0.0.0.0` / `::` / empty) is not navigable, so open localhost on the same - * port instead — the same address the ready banner's `Local:` line shows. + * (`0.0.0.0` / `::` / empty, bracketed or bare) is not navigable, so open + * localhost on the same port instead — the same address the ready banner's + * `Local:` line shows. */ export function browserOpenOrigin(origin: string): string { const separator = origin.lastIndexOf(':'); const host = origin.slice(origin.indexOf('://') + 3, separator); - if (!isWildcard(host)) return origin; + if (!isWildcard(host.replace(/^\[/, '').replace(/\]$/, ''))) return origin; return `http://localhost${origin.slice(separator)}`; } diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 0a0ce27b4..acc20fd3a 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -40,7 +40,7 @@ import { isLoopbackHost, splitTokenFragment, } from './access-urls'; -import { type NetworkAddress } from './networks'; +import { formatHostForUrl, type NetworkAddress } from './networks'; import { formatRemoteControlOutput, formatRemoteControlStatus, @@ -419,7 +419,7 @@ async function runServerInProcess( }); logger.info('serving the REST/WS API and the bundled web UI'); running = { - address: `http://${v2.host}:${v2.port}`, + address: `http://${formatHostForUrl(v2.host, v2.host.includes(':') ? 'IPv6' : 'IPv4')}:${v2.port}`, logger, close: () => v2.close(), }; diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 093e4901a..e1f96ffae 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -403,7 +403,7 @@ describe('`pythinker web` opens the browser', () => { it('opens localhost for a wildcard IPv6 bind', async () => { const { handleWebCommand } = await import('#/cli/sub/web/run'); - const { runner } = makeRunner('http://:::58627'); + const { runner } = makeRunner('http://[::]:58627'); const { stdout, stderr } = makeIo(); const openUrl = vi.fn(); @@ -421,6 +421,26 @@ describe('`pythinker web` opens the browser', () => { expect(openUrl).toHaveBeenCalledWith('http://localhost:58627'); }); + it('opens the bracketed loopback origin for an IPv6 bind', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://[::1]:58627'); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { host: '::1', open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://[::1]:58627/#token=tok-xyz'); + }); + it('does not open the browser when open is false', async () => { const { handleWebCommand } = await import('#/cli/sub/web/run'); const { runner } = makeRunner('http://127.0.0.1:9000'); @@ -1079,6 +1099,7 @@ describe('browserOpenOrigin', () => { const { browserOpenOrigin } = await import('#/cli/sub/web/access-urls'); expect(browserOpenOrigin('http://0.0.0.0:58627')).toBe('http://localhost:58627'); expect(browserOpenOrigin('http://:::58627')).toBe('http://localhost:58627'); + expect(browserOpenOrigin('http://[::]:58627')).toBe('http://localhost:58627'); }); it('keeps navigable origins unchanged', async () => {