Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resume-subagent-after-restart.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/telemetry-model-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Include the bound model in telemetry event context.
5 changes: 5 additions & 0 deletions .changeset/web-open-localhost.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions apps/pythinker-code/src/cli/sub/web/access-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ 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, 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.replace(/^\[/, '').replace(/\]$/, ''))) 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';
Expand Down
10 changes: 6 additions & 4 deletions apps/pythinker-code/src/cli/sub/web/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ import {
} from '../../version';
import {
accessUrlLines,
browserOpenOrigin,
buildOpenableUrl,
isLoopbackHost,
splitTokenFragment,
} from './access-urls';
import { type NetworkAddress } from './networks';
import { formatHostForUrl, type NetworkAddress } from './networks';
import {
formatRemoteControlOutput,
formatRemoteControlStatus,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -417,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(),
};
Expand Down Expand Up @@ -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
? [
Expand Down
76 changes: 76 additions & 0 deletions apps/pythinker-code/test/cli/web/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,66 @@ 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('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');
Expand Down Expand Up @@ -1034,6 +1094,22 @@ 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');
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;
Expand Down
24 changes: 19 additions & 5 deletions packages/agent-core-v2/src/agent/profile/profileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -545,11 +551,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ

private afterConfigDispatch(changed: Omit<ProfileUpdateData, 'activeToolNames'>): 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();
Expand All @@ -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();
Expand Down
63 changes: 45 additions & 18 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -253,10 +262,10 @@ export class SubagentTool implements ISubagentTool {
};
}

private resumeProfileName(agentId: string): string | undefined {
private async resumeProfileName(agentId: string): Promise<string | undefined> {
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(
Expand All @@ -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;
Expand Down Expand Up @@ -349,12 +352,15 @@ export class SubagentTool implements ISubagentTool {
};
}

private async ensureOwnedIdleSubagent(
agentId: string,
target: IAgentScopeHandle,
): Promise<void> {
private async resolveResumeTarget(agentId: string): Promise<IAgentScopeHandle> {
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)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, {
details: { agentId },
});
Expand All @@ -366,13 +372,34 @@ 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,
`Agent instance "${agentId}" is already running and cannot run concurrently`,
{ details: { agentId } },
);
}
return target;
}

private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise<IAgentScopeHandle> {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/features/tower/tower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading