From 3c074d6beb85a31e4ccd118220140ee4c39ebbe9 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sun, 4 Oct 2026 03:20:02 +0000 Subject: [PATCH 1/4] fix(agent): resolve hostname-era Agentlet placement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modules/agent/acp/agent-cli.route.test.ts | 4 +- .../src/modules/agent/acp/agent-cli.route.ts | 9 ++- .../agent/acp/external-agent-realization.ts | 5 +- .../modules/agent/acp/profiles.route.test.ts | 35 +++++++++++ .../src/modules/agent/acp/profiles.route.ts | 24 ++++---- .../acp/threads.route.cached-meta.test.ts | 30 ++++++++++ .../src/modules/agent/acp/threads.route.ts | 4 +- .../agent/agent-defaults.route.test.ts | 3 +- .../src/modules/agent/agent-defaults.route.ts | 5 +- .../src/modules/agent/agent-defaults.test.ts | 20 +++++-- .../src/modules/agent/agent-defaults.ts | 8 +-- .../agent/selectable-agent-profile.test.ts | 22 ++++++- .../modules/agent/selectable-agent-profile.ts | 16 ++--- docs/architecture/agent-profiles.md | 2 + .../packages/acp-driver/src/handle.ts | 18 +++++- .../packages/acp-driver/src/placement.test.ts | 56 ++++++++++++++++++ .../src/session-self-repair.test.ts | 1 + .../acp-driver/src/spawn-orchestrator.ts | 9 ++- .../src/agentlet-resolution.test.ts | 59 +++++++++++++++++++ .../agentlet-host/src/agentlet-resolution.ts | 38 ++++++++++++ .../packages/agentlet-host/src/index.ts | 4 ++ 21 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts create mode 100644 external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts index a8b3aae5c..194dd8d08 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.test.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.test.ts @@ -16,6 +16,8 @@ vi.mock('@agenetes/agentlet-host', () => ({ getAgentlet: () => ({ status: 'connected' }), discoverHarnesses: mocks.discover, }), + resolveConnectedAgentletId: (target: string) => + target === 'legacy-host' ? 'remote-machine' : target, })); let app: FastifyInstance | undefined; @@ -119,7 +121,7 @@ describe('ACP agent CLI route', () => { }); it('queries the edited Profile machine and projects Custom for older catalogues', async () => { - mocks.getProfile.mockReturnValue({ agentletId: 'remote-machine' }); + mocks.getProfile.mockReturnValue({ agentletId: 'legacy-host' }); mocks.discover.mockResolvedValue({ harnesses: [] }); app = Fastify({ logger: false }); await app.register(createAcpAgentCliRoutes(), { prefix: '/api/acp' }); diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts index 24d053434..a173f3737 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -18,6 +18,7 @@ import { getAgentletGateway, getAgentProfileRegistry, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { CUSTOM_COMMAND_CAPABILITIES, @@ -46,8 +47,12 @@ async function detectAgentClis(target: { : undefined; if (target.profileId && !profile) throw new Error('Agent Profile is unavailable'); - const agentletId = profile?.agentletId ?? target.agentletId; - if (!agentletId) throw new Error('Agentlet target is required'); + const requestedAgentletId = profile?.agentletId ?? target.agentletId; + if (!requestedAgentletId) throw new Error('Agentlet target is required'); + const agentletId = profile + ? resolveConnectedAgentletId(requestedAgentletId) + : requestedAgentletId; + if (!agentletId) throw new Error('Agentlet is not connected'); const connection = gateway.getAgentlet(agentletId); if (connection?.status !== 'connected') throw new Error('Agentlet is not connected'); diff --git a/apps/server/src/modules/agent/acp/external-agent-realization.ts b/apps/server/src/modules/agent/acp/external-agent-realization.ts index 563117060..f10d864f8 100644 --- a/apps/server/src/modules/agent/acp/external-agent-realization.ts +++ b/apps/server/src/modules/agent/acp/external-agent-realization.ts @@ -6,6 +6,7 @@ import { ensureAcpSession, resolveAcpAgentletId, } from '@agenetes/acp-driver'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { canvasAcpNamespace } from '../../workspace/paths.js'; import { @@ -123,8 +124,10 @@ async function ensureSessionFromCanonicalSpec( ? { ...resolvedEnvironment, ...spec.spec.env } : undefined; const record = await agenetes.record(spec.namespace, spec.threadId); + const requestedAgentletId = resolveAcpAgentletId(spec); return ensureAcpSession({ - agentletId: resolveAcpAgentletId(spec), + agentletId: + resolveConnectedAgentletId(requestedAgentletId) ?? requestedAgentletId, threadId: spec.threadId, binding: spec.spec.binding, namespace: spec.namespace, diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index dab86c012..5f9583fbb 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -51,6 +51,13 @@ vi.mock('@agenetes/agentlet-host', () => ({ }, })), }), + resolveConnectedAgentletId: (target: string) => { + if (mocks.connectedIds.has(target)) return target; + const matches = [...mocks.connectedIds].filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, })); vi.mock('./profile-schema-cache.js', () => ({ @@ -198,11 +205,39 @@ describe('ordinary Profile catalog routes', () => { }), ], }); + expect(mocks.registry.createProfile).not.toHaveBeenCalled(); expect(mocks.initializeDefaults).not.toHaveBeenCalled(); expect(mocks.discoverHarnesses).not.toHaveBeenCalled(); }); + it('maps one hostname-era Profile to the unique connected device', async () => { + const legacyProfile = { + ...commandProfile, + id: 'legacy-command', + agentletId: 'machine-a-host', + }; + mocks.registry.listProfiles.mockReturnValue([legacyProfile]); + const server = await setup(); + const response = await server.inject('/api/acp/profiles'); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + profiles: [legacyProfile], + selectableProfileIds: ['legacy-command'], + connectedDevices: [ + expect.objectContaining({ + agentletId: 'machine-a', + profileCount: 1, + }), + expect.objectContaining({ + agentletId: 'remote-machine', + profileCount: 0, + }), + ], + }); + }); + it('rejects caller-created automatic provenance', async () => { const server = await setup(); const response = await server.inject({ diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index 9e4fe6d3d..54d15d85d 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -29,6 +29,7 @@ import { getAgentProfileRegistry, getAgentletGateway, getDaemonSupervisor, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -88,7 +89,10 @@ async function validateHarnessLaunch( try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const result = await gateway.discoverHarnesses(agentletId, { + const resolvedAgentletId = resolveConnectedAgentletId(agentletId); + if (!resolvedAgentletId) + throw new Error('The selected Agentlet is not connected'); + const result = await gateway.discoverHarnesses(resolvedAgentletId, { prepareWorkspaces: false, }); const harness = result.harnesses.find( @@ -117,7 +121,7 @@ async function validateHarnessLaunch( }); return false; } - await gateway.buildHarnessLaunch(agentletId, { launch }); + await gateway.buildHarnessLaunch(resolvedAgentletId, { launch }); return true; } catch (error) { request.log.warn( @@ -156,7 +160,9 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { try { const gateway = getAgentletGateway(); if (!gateway) throw new Error('Agentlet Gateway is not ready'); - const agentletId = profile?.agentletId ?? parsed.data.agentletId; + const agentletId = profile + ? resolveConnectedAgentletId(profile.agentletId) + : parsed.data.agentletId; if (!agentletId || !isAgentletConnected(agentletId)) { return reply.status(409).send({ code: 'agentlet_unavailable', @@ -190,15 +196,11 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { } const profiles = registry.listProfiles(); const connected = getConnectedAgentlets(); - const connectedIds = new Set( - connected.map((connection) => connection.agentletId), - ); const profileCounts = new Map(); for (const profile of profiles) { - profileCounts.set( - profile.agentletId, - (profileCounts.get(profile.agentletId) ?? 0) + 1, - ); + const agentletId = resolveConnectedAgentletId(profile.agentletId); + if (!agentletId) continue; + profileCounts.set(agentletId, (profileCounts.get(agentletId) ?? 0) + 1); } const connectedDevices = connected .map((connection) => ({ @@ -225,7 +227,7 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { return { profiles, selectableProfileIds: profiles - .filter((profile) => connectedIds.has(profile.agentletId)) + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) .map((profile) => profile.id), connectedDevices, agentlet: getDaemonSupervisor().getStatus(), diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts index 66dfbc803..9c2b3a8ea 100644 --- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts @@ -29,6 +29,11 @@ vi.mock('@agenetes/acp-driver', () => ({ }, })); +vi.mock('@agenetes/agentlet-host', () => ({ + resolveConnectedAgentletId: (agentletId: string) => + agentletId === 'legacy-host' ? 'device-uuid' : agentletId, +})); + vi.mock('./external-agent-realization.js', () => ({ externalAgentRealization: { realize: vi.fn(), ensureSession: vi.fn() }, realizationHttpError: () => ({ @@ -115,6 +120,7 @@ describe('ACP cached-meta across awaited persistence', () => { usage: null, metaUpdatedAt: 32, }); + const server = await createApp(); const response = await server.inject({ @@ -135,6 +141,30 @@ describe('ACP cached-meta across awaited persistence', () => { }); }); + it('finds a live session through unique hostname-era placement resolution', async () => { + mocks.record = externalRecord('legacy-host'); + mocks.live.set('device-uuid\u0000thread-1', { + availableCommands: [{ name: 'review', description: 'Review changes' }], + commandsUpdatedAt: 5, + availableModes: [], + currentModeId: null, + availableModels: [], + currentModelId: null, + configOptions: [], + selections: {}, + sessionInfo: null, + usage: null, + metaUpdatedAt: 6, + }); + const response = await (await createApp()).inject(CACHED_META_URL); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + source: 'thread', + availableCommands: [{ name: 'review' }], + }); + }); + it('does not invent a placement for a thread with no record', async () => { mocks.live.set('agentlet-supervised\u0000thread-1', { availableCommands: [], diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index df778ecb0..1950b9cbe 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { acpSessionRegistry } from '@agenetes/acp-driver'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { acpPermissionDecisionSchema, @@ -151,7 +152,8 @@ async function resolveThreadAgentletId( typeof driverSpec === 'object' && typeof (driverSpec as { agentletId?: unknown }).agentletId === 'string' ) { - return (driverSpec as { agentletId: string }).agentletId; + const agentletId = (driverSpec as { agentletId: string }).agentletId; + return resolveConnectedAgentletId(agentletId) ?? agentletId; } } return undefined; diff --git a/apps/server/src/modules/agent/agent-defaults.route.test.ts b/apps/server/src/modules/agent/agent-defaults.route.test.ts index 7a98899d1..7832a8512 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.test.ts @@ -26,7 +26,8 @@ vi.mock('./agent-defaults.js', () => ({ vi.mock('@agenetes/agentlet-host', () => ({ getAgentProfileRegistry: () => mocks.registryReady ? { getProfile: mocks.getProfile } : null, - getAgentletGateway: () => ({ getAgentlet: mocks.getAgentlet }), + resolveConnectedAgentletId: (target: string) => + mocks.getAgentlet(target)?.status === 'connected' ? target : undefined, })); vi.mock('./acp/profile-schema-cache.js', () => ({ getProfileSchemaCache: mocks.getCache, diff --git a/apps/server/src/modules/agent/agent-defaults.route.ts b/apps/server/src/modules/agent/agent-defaults.route.ts index 49d537e35..9412298bd 100644 --- a/apps/server/src/modules/agent/agent-defaults.route.ts +++ b/apps/server/src/modules/agent/agent-defaults.route.ts @@ -3,7 +3,7 @@ import { getAgentProfileRegistry, - getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { @@ -60,8 +60,7 @@ function projectDefaults(defaults: AgentDefaults): AgentDefaultsResponse { ? 'offline' : !profile ? 'deleted' - : getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected' + : resolveConnectedAgentletId(profile.agentletId) ? 'available' : 'offline', // Missing observations and editable CLI labels do not prove lack of support. diff --git a/apps/server/src/modules/agent/agent-defaults.test.ts b/apps/server/src/modules/agent/agent-defaults.test.ts index f900a4d99..0610c7cda 100644 --- a/apps/server/src/modules/agent/agent-defaults.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.test.ts @@ -12,11 +12,8 @@ import { import type { AgentDefaults, AgentProfileView } from '@huabu/shared'; vi.mock('@agenetes/agentlet-host', () => ({ - getAgentletGateway: () => ({ - getAgentlet: (id: string) => ({ - status: id === 'connected' ? 'connected' : 'disconnected', - }), - }), + resolveConnectedAgentletId: (id: string) => + id === 'connected' || id === 'legacy-host' ? 'connected' : undefined, })); function profile( @@ -59,6 +56,19 @@ describe('installation Agent defaults', () => { } }); + it('offers a uniquely resolved hostname-era candidate to initialization', () => { + const initialize = vi + .spyOn(AgentDefaultsService.prototype, 'initializeAgentDefaults') + .mockReturnValue({ profileId: null, functionalModel: '' }); + try { + const legacy = profile('legacy', 'legacy-host'); + initializeAgentDefaults([legacy]); + expect(initialize).toHaveBeenCalledWith([legacy]); + } finally { + initialize.mockRestore(); + } + }); + it('requires an explicit external default instead of falling back to Huabu', () => { const { service, storage } = setup(); expect(() => service.requireDefaultAgentProfileId()).toThrow( diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts index 3d5c3af62..0accc5686 100644 --- a/apps/server/src/modules/agent/agent-defaults.ts +++ b/apps/server/src/modules/agent/agent-defaults.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getAgentletGateway } from '@agenetes/agentlet-host'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { agentDefaultsSchema } from '@huabu/shared'; @@ -134,9 +134,7 @@ export const initializeAgentDefaults = ( profiles: readonly AgentProfileView[], ): AgentDefaults => service.initializeAgentDefaults( - profiles.filter( - (profile) => - getAgentletGateway()?.getAgentlet(profile.agentletId)?.status === - 'connected', + profiles.filter((profile) => + resolveConnectedAgentletId(profile.agentletId), ), ); diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts index 0f649404f..0ce944a7c 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -14,9 +14,13 @@ vi.mock('@agenetes/agentlet-host', () => ({ host.profiles.find((profile) => profile.id === profileId), listProfiles: () => host.profiles, }), - getAgentletGateway: () => ({ - getAgentlets: () => host.connectedIds.map((agentletId) => ({ agentletId })), - }), + resolveConnectedAgentletId: (target: string) => { + if (host.connectedIds.includes(target)) return target; + const matches = host.connectedIds.filter( + (agentletId) => `${agentletId}-host` === target, + ); + return matches.length === 1 ? matches[0] : undefined; + }, })); import { @@ -75,6 +79,18 @@ describe('listAvailableAgentProfiles', () => { ]); }); + it('projects a hostname-era Profile when exactly one device reports that hostname', () => { + host.profiles = [ + { id: 'legacy', alias: 'Legacy', agentletId: 'device-a-host' }, + ]; + host.connectedIds = ['device-a']; + + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + { id: 'legacy', alias: 'Legacy', default: true }, + ]); + }); + it('accepts the Huabu Profile without an external registry', () => { expect(() => requireAvailableAgentProfile('huabu', null)).not.toThrow(); }); diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index 8b69df24e..a67e1d315 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -3,7 +3,7 @@ import { getAgentProfileRegistry, - getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { HUABU_AGENT_PROFILE_ID } from '@huabu/shared'; @@ -26,17 +26,11 @@ function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { if (!registry) return null; return { getProfile: (profileId) => registry.getProfile(profileId), - listSelectableProfileIds: () => { - const connectedIds = new Set( - (getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? []).map( - (connection) => connection.agentletId, - ), - ); - return registry + listSelectableProfileIds: () => + registry .listProfiles() - .filter((profile) => connectedIds.has(profile.agentletId)) - .map((profile) => profile.id); - }, + .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .map((profile) => profile.id), }; } diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 04c7fd28f..f86b24f18 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -25,6 +25,8 @@ Huabu subscribes to machine connection events, includes machines already connect The Agentlet CLI owns its default device identity. On first start it atomically creates `~/.agentlet/device.json` containing `{ "version": 1, "deviceId": "" }`; later starts reuse that UUID, so identity survives daemon, Huabu, OS-session, binary, upgrade, and worktree restarts while the home directory remains. A malformed or unreadable identity file fails explicitly instead of rotating identity. `--agentlet-id` remains an advanced exact override and does not rewrite the persisted UUID. Hello metadata reports the real hostname, platform, and architecture separately for display; none of those fields participates in identity. Windows and WSL naturally receive distinct identities because they use separate home directories. +Hostname-era Profiles and frozen workload records remain compatible without rewriting persisted data. Live placement resolution first requires an exact connected `agentletId`; when none exists, it treats the stored target as a legacy hostname and accepts it only when exactly one connected Agentlet reports that hostname. The resolved UUID is used for availability, device projections, Profile operations, and ACP session routing. Zero or multiple hostname matches remain unavailable, so Windows/WSL collisions never route to an arbitrary device. + Multiple Agentlets may share one Huabu connection token and connect concurrently. If an identity is already online, Gateway rejects the second live connection with guidance to retry using `--agentlet-id ` instead of evicting the first device; once the prior connection is disconnected, the same identity follows the normal reconnect path. The embedded supervisor is special only as process-lifecycle infrastructure: it resolves, starts, restarts, stops, and diagnoses its child, but it neither assigns that Agentlet's identity nor acts as a placement default. ## Profile identity and customization diff --git a/external/agenetes/packages/acp-driver/src/handle.ts b/external/agenetes/packages/acp-driver/src/handle.ts index 986fd35fd..78a21e7f9 100644 --- a/external/agenetes/packages/acp-driver/src/handle.ts +++ b/external/agenetes/packages/acp-driver/src/handle.ts @@ -33,6 +33,7 @@ import { randomUUID } from 'node:crypto'; +import { resolveConnectedAgentletId } from '@agenetes/agentlet-host'; import { resolveAgentInputs } from '@agenetes/protocol'; import { HistoryLoadDeniedError, @@ -328,15 +329,25 @@ export class AcpAgentHandle< getIdleTimeoutSecs: () => 600, }, ) { - this.agentletId = resolveAcpAgentletId(spec); + this.requestedAgentletId = resolveAcpAgentletId(spec); + this.agentletId = + resolveConnectedAgentletId(this.requestedAgentletId) ?? + this.requestedAgentletId; // Jobs may share a durable thread or have none. Their live sessions must not. this.sessionThreadId = spec.workloadType === 'Job' ? `acp-job-${randomUUID()}` : spec.threadId; } - private readonly agentletId: string; + private readonly requestedAgentletId: string; + private agentletId: string; private readonly sessionThreadId: string; + private resolveAgentletId(): string { + this.agentletId = + resolveConnectedAgentletId(this.requestedAgentletId) ?? this.agentletId; + return this.agentletId; + } + private async authorizeHistoryLoad( mode: 'recover' | 'fork', turns: readonly AgentTurn[], @@ -425,8 +436,9 @@ export class AcpAgentHandle< this.spec.spec, this.runtimePolicy, ); + const agentletId = this.resolveAgentletId(); return ensureAcpSession({ - agentletId: this.agentletId, + agentletId, threadId: this.sessionThreadId, workloadType: this.spec.workloadType, binding: this.spec.spec.binding, diff --git a/external/agenetes/packages/acp-driver/src/placement.test.ts b/external/agenetes/packages/acp-driver/src/placement.test.ts index 9b08f2aca..974d6df16 100644 --- a/external/agenetes/packages/acp-driver/src/placement.test.ts +++ b/external/agenetes/packages/acp-driver/src/placement.test.ts @@ -10,6 +10,28 @@ vi.mock('@agenetes/agentlet-host', async (importOriginal) => { return { ...actual, getAgentletGateway: () => host.gateway, + resolveConnectedAgentletId: (target: string) => { + const gateway = host.gateway as + | { + getAgentlet?: ( + agentletId: string, + ) => { agentletId?: string; status?: string } | undefined; + getAgentlets?: () => Array<{ + agentletId: string; + status: string; + agentletProfile?: { machine?: { hostname?: string } }; + }>; + } + | undefined; + const exact = gateway?.getAgentlet?.(target); + if (exact?.status === 'connected') return exact.agentletId ?? target; + const matches = (gateway?.getAgentlets?.() ?? []).filter( + (connection) => + connection.status === 'connected' && + connection.agentletProfile?.machine?.hostname === target, + ); + return matches.length === 1 ? matches[0]?.agentletId : undefined; + }, }; }); @@ -37,6 +59,40 @@ afterEach(() => { }); describe('explicit ACP placement', () => { + it('routes a hostname-era workload to its unique connected device identity', async () => { + const sessions = new Map(); + const spawnOnAgentlet = vi.fn( + async (agentletId: string, params: { appId: string }) => { + const sessionId = `${agentletId}-${params.appId}`; + sessions.set(JSON.stringify([agentletId, sessionId]), { + status: 'connected', + }); + return { sessionId, pid: 101 }; + }, + ); + host.gateway = { + getAgentlet: () => undefined, + getAgentlets: () => [ + { + agentletId: 'device-uuid', + status: 'connected', + agentletProfile: { machine: { hostname: 'legacy-host' } }, + }, + ], + getSession: (agentletId: string, sessionId: string) => + sessions.get(JSON.stringify([agentletId, sessionId])), + spawnOnAgentlet, + }; + + await expect( + ensureAgentForThread('legacy-host', 'legacy-thread', recipe), + ).resolves.toMatchObject({ agentletId: 'device-uuid' }); + expect(spawnOnAgentlet).toHaveBeenCalledWith( + 'device-uuid', + expect.any(Object), + ); + }); + it('forwards a persisted structured plan and retains it when reusing the live process', async () => { const launch = { kind: 'acp-harness' as const, diff --git a/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts b/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts index 6a47ec5c7..eadb42ec0 100644 --- a/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts +++ b/external/agenetes/packages/acp-driver/src/session-self-repair.test.ts @@ -21,6 +21,7 @@ const clients = vi.hoisted(() => ({ vi.mock('@agenetes/agentlet-host', () => ({ getAgentletGateway: () => host.gateway, + resolveConnectedAgentletId: (agentletId: string) => agentletId, })); vi.mock('./spawn-orchestrator.js', () => orchestrator); diff --git a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts index 6571149ba..b29a32123 100644 --- a/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts +++ b/external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts @@ -40,6 +40,7 @@ import { AgentletRequestError, getAgentletGateway, + resolveConnectedAgentletId, } from '@agenetes/agentlet-host'; import { harnessLaunchPlanSchema, @@ -96,10 +97,8 @@ function agentletThreadKey(agentletId: string, threadId: string): string { /** Resolve one explicitly targeted execution node. */ function readTargetAgentlet(agentletId: string): { agentletId: string } | null { - const gateway = getAgentletGateway(); - if (!gateway) return null; - const agentlet = gateway.getAgentlet(agentletId); - return agentlet?.status === 'connected' ? { agentletId } : null; + const resolved = resolveConnectedAgentletId(agentletId); + return resolved ? { agentletId: resolved } : null; } /** @@ -205,7 +204,7 @@ export async function ensureAgentForThread( ); } - const cacheKey = agentletThreadKey(agentletId, threadId); + const cacheKey = agentletThreadKey(agentlet.agentletId, threadId); const cached = threadToAgent.get(cacheKey); if (cached) { const gateway = getAgentletGateway(); diff --git a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts new file mode 100644 index 000000000..7c29230bb --- /dev/null +++ b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveConnectedAgentletIdFromConnections } from './agentlet-resolution.js'; + +import type { AgentletConnection } from '@agenetes/agentlet-gateway'; + +function connection( + agentletId: string, + hostname: string, + status: 'connected' | 'disconnected' = 'connected', +): AgentletConnection { + return { + agentletId, + status, + connectedAt: new Date(), + agentletProfile: { + bridge: { name: 'agentlet', version: '1.0.0' }, + machine: { hostname, platform: 'linux', arch: 'x64' }, + capabilities: { autoRestart: true, bufferLimit: 1000 }, + }, + } as AgentletConnection; +} + +describe('resolveConnectedAgentletIdFromConnections', () => { + it('prefers an exact connected identity over hostname compatibility', () => { + expect( + resolveConnectedAgentletIdFromConnections('device-a', [ + connection('device-a', 'other-host'), + connection('device-b', 'device-a'), + ]), + ).toBe('device-a'); + }); + + it('resolves one connected legacy hostname to its current identity', () => { + expect( + resolveConnectedAgentletIdFromConnections('legacy-host', [ + connection('device-a', 'legacy-host'), + ]), + ).toBe('device-a'); + }); + + it('does not resolve absent or ambiguous legacy hostnames', () => { + const devices = [ + connection('device-a', 'shared-host'), + connection('device-b', 'shared-host'), + connection('device-c', 'offline-host', 'disconnected'), + ]; + + expect( + resolveConnectedAgentletIdFromConnections('missing-host', devices), + ).toBeUndefined(); + expect( + resolveConnectedAgentletIdFromConnections('shared-host', devices), + ).toBeUndefined(); + expect( + resolveConnectedAgentletIdFromConnections('offline-host', devices), + ).toBeUndefined(); + }); +}); diff --git a/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts new file mode 100644 index 000000000..3c3af80b7 --- /dev/null +++ b/external/agenetes/packages/agentlet-host/src/agentlet-resolution.ts @@ -0,0 +1,38 @@ +import { getAgentletGateway } from './gateway-mount.js'; + +import type { AgentletConnection } from '@agenetes/agentlet-gateway'; + +type ConnectedAgentlet = Pick< + AgentletConnection, + 'agentletId' | 'agentletProfile' | 'status' +>; + +export function resolveConnectedAgentletIdFromConnections( + target: string, + connections: readonly ConnectedAgentlet[], +): string | undefined { + const connected = connections.filter( + (connection) => connection.status === 'connected', + ); + const exact = connected.find( + (connection) => connection.agentletId === target, + ); + if (exact) return exact.agentletId; + + const hostnameMatches = connected.filter( + (connection) => + connection.agentletProfile?.machine?.hostname === target, + ); + return hostnameMatches.length === 1 + ? hostnameMatches[0]?.agentletId + : undefined; +} + +export function resolveConnectedAgentletId( + target: string, +): string | undefined { + return resolveConnectedAgentletIdFromConnections( + target, + getAgentletGateway()?.getAgentlets({ status: 'connected' }) ?? [], + ); +} diff --git a/external/agenetes/packages/agentlet-host/src/index.ts b/external/agenetes/packages/agentlet-host/src/index.ts index f0a3c4d88..ac27e0371 100644 --- a/external/agenetes/packages/agentlet-host/src/index.ts +++ b/external/agenetes/packages/agentlet-host/src/index.ts @@ -29,6 +29,10 @@ import type { import type { FastifyInstance } from 'fastify'; export { getAgentProfileRegistry } from './agent-profile-mount.js'; +export { + resolveConnectedAgentletId, + resolveConnectedAgentletIdFromConnections, +} from './agentlet-resolution.js'; export { ACP_UPGRADE_PATH, getAgentletGateway, From 94d81e91b41dec4b14fb9b8d71c12be721ffea30 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sun, 4 Oct 2026 03:38:25 +0000 Subject: [PATCH 2/4] fix(agent): use readable Agentlet device labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../acp/harness-profile-discovery.test.ts | 17 ++++- .../agent/acp/harness-profile-discovery.ts | 20 +++++- .../modules/agent/acp/profiles.route.test.ts | 1 + .../src/modules/agent/acp/profiles.route.ts | 4 ++ .../agent/agentlet-device-display.test.ts | 28 ++++++++ .../modules/agent/agentlet-device-display.ts | 19 ++++++ .../AgentProfileEditor.test.tsx | 64 +++++++++++++++---- .../agent-profiles/CommandProfileForm.tsx | 22 ++++++- .../ExternalAgentsSettings.test.tsx | 2 + .../agent-profiles/ExternalAgentsSettings.tsx | 3 +- docs/architecture/agent-profiles.md | 2 + packages/shared/src/types/api/acp.ts | 1 + 12 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/modules/agent/agentlet-device-display.test.ts create mode 100644 apps/server/src/modules/agent/agentlet-device-display.ts diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts index e948a7029..3f3f935ef 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts @@ -63,7 +63,17 @@ function setup(initialMachines = ['machine-a']) { }), }; const gateway = { - getAgentlets: () => initialMachines.map((agentletId) => ({ agentletId })), + getAgentlets: () => + initialMachines.map((agentletId) => ({ + agentletId, + agentletProfile: { + machine: { + hostname: agentletId, + platform: 'linux', + arch: 'x64', + }, + }, + })), onAgentletsChanged: (handler: (event: Event) => void) => { listener = handler; return vi.fn(); @@ -116,6 +126,7 @@ describe('automatic ordinary Profile provisioning', () => { }, ); expect(context.profiles[0]).toMatchObject({ + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/home/user/.agentlet/workspace/copilot', launch: { kind: 'acp-command', command: 'copilot --acp' }, @@ -277,7 +288,7 @@ describe('automatic ordinary Profile provisioning', () => { const registry = createAgentProfileRegistry({ storageDir }); const legacy = registry.createProfile({ launchKind: 'acp-command', - alias: 'GitHub Copilot (machine-a)', + alias: 'GitHub Copilot (machine-a: linux x64)', agentletId: 'machine-a', workingDirPath: '/custom/work', command: 'copilot --acp --model old-model', @@ -328,7 +339,7 @@ describe('automatic ordinary Profile provisioning', () => { .listProfiles() .find((profile) => profile.launch.kind === 'acp-harness'); expect(typed).toMatchObject({ - alias: 'GitHub Copilot (machine-a) [copilot]', + alias: 'GitHub Copilot (machine-a: linux x64) [copilot]', workingDirPath: observation.harnesses[0].workingDirPath, launch: { kind: 'acp-harness', diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts index 3271b5c20..12c2133d8 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts @@ -3,6 +3,8 @@ import { CUSTOM_COMMAND_WRAPPER_ID } from '@agentlet/protocol'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; + import type { AgentProfile, AgentProfileRegistry, @@ -76,7 +78,16 @@ export function mergeProfileCustomData( } interface DiscoveryGateway { - getAgentlets(filter: { status: 'connected' }): Array<{ agentletId: string }>; + getAgentlets(filter: { status: 'connected' }): Array<{ + agentletId: string; + agentletProfile?: { + machine?: { + hostname?: string; + platform?: string; + arch?: string; + }; + }; + }>; onAgentletsChanged( handler: (event: { agentletId: string; @@ -149,7 +160,12 @@ export function registerHarnessProfileDiscovery({ ); }); if (existing) continue; - const defaultAlias = `${harness.displayName} (${agentletId})`; + const connection = gateway + .getAgentlets({ status: 'connected' }) + .find((candidate) => candidate.agentletId === agentletId); + const defaultAlias = `${harness.displayName} (${formatAgentletDeviceDisplayName( + connection?.agentletProfile?.machine, + )})`; const common = { agentletId, alias: diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index 5f9583fbb..ed5032b9a 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -197,6 +197,7 @@ describe('ordinary Profile catalog routes', () => { connectedDevices: [ expect.objectContaining({ agentletId: 'machine-a', + displayName: 'machine-a-host: linux x64', profileCount: 1, }), expect.objectContaining({ diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index 54d15d85d..0386a0a76 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -49,6 +49,7 @@ import { getAgentDefaults, initializeAgentDefaults, } from '../agent-defaults.js'; +import { formatAgentletDeviceDisplayName } from '../agentlet-device-display.js'; import type { AgentletConnection } from '@agenetes/agentlet-host'; import type { @@ -205,6 +206,9 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { const connectedDevices = connected .map((connection) => ({ agentletId: connection.agentletId, + displayName: formatAgentletDeviceDisplayName( + connection.agentletProfile?.machine, + ), ...(connection.agentletProfile?.machine?.hostname ? { hostname: connection.agentletProfile.machine.hostname } : {}), diff --git a/apps/server/src/modules/agent/agentlet-device-display.test.ts b/apps/server/src/modules/agent/agentlet-device-display.test.ts new file mode 100644 index 000000000..a1ed05980 --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { formatAgentletDeviceDisplayName } from './agentlet-device-display.js'; + +describe('formatAgentletDeviceDisplayName', () => { + it('combines hostname and environment without exposing device identity', () => { + expect( + formatAgentletDeviceDisplayName({ + hostname: 'huabu', + platform: 'linux', + arch: 'x64', + }), + ).toBe('huabu: linux x64'); + }); + + it('omits missing metadata and retains a non-identity fallback', () => { + expect(formatAgentletDeviceDisplayName({ hostname: 'huabu' })).toBe( + 'huabu', + ); + expect( + formatAgentletDeviceDisplayName({ platform: 'linux', arch: 'arm64' }), + ).toBe('Agentlet: linux arm64'); + expect(formatAgentletDeviceDisplayName()).toBe('Agentlet'); + }); +}); diff --git a/apps/server/src/modules/agent/agentlet-device-display.ts b/apps/server/src/modules/agent/agentlet-device-display.ts new file mode 100644 index 000000000..e67aa270a --- /dev/null +++ b/apps/server/src/modules/agent/agentlet-device-display.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +interface AgentletMachineDisplayMetadata { + hostname?: string; + platform?: string; + arch?: string; +} + +export function formatAgentletDeviceDisplayName( + machine?: AgentletMachineDisplayMetadata, +): string { + const hostname = machine?.hostname?.trim() || 'Agentlet'; + const environment = [machine?.platform, machine?.arch] + .map((value) => value?.trim()) + .filter(Boolean) + .join(' '); + return environment ? `${hostname}: ${environment}` : hostname; +} diff --git a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx index fc388e428..9df45744c 100644 --- a/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx +++ b/apps/web/src/components/Settings/agent-profiles/AgentProfileEditor.test.tsx @@ -13,6 +13,7 @@ import type { AcpAgentCliInfo, AcpProfileLaunchPreviewResponse, AgentProfileView, + ConnectedAgentletDevice, } from '@huabu/shared'; declare global { @@ -65,7 +66,12 @@ vi.mock('@/components/Common/Select', () => ({ ariaLabel, }: { value: string; - options: { value: string; label: string; disabled?: boolean }[]; + options: { + value: string; + label: string; + description?: string; + disabled?: boolean; + }[]; onChange: (value: string) => void; ariaLabel?: string; }) => ( @@ -82,6 +88,7 @@ vi.mock('@/components/Common/Select', () => ({ disabled={option.disabled} > {option.label} + {option.description ? ` (${option.description})` : ''} ))} @@ -148,6 +155,18 @@ function renderEditor( editing?: AgentProfileView, clis = agents, loaded = true, + connectedDevices: ConnectedAgentletDevice[] = [ + { + agentletId: editing?.agentletId ?? 'device-1', + displayName: 'Test device: linux x64', + hostname: 'Test device', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 0, + }, + ], ) { if (!container) { container = document.createElement('div'); @@ -162,17 +181,7 @@ function renderEditor( : ({ mode: 'create' } as const))} detectedClis={clis} detectionLoaded={loaded} - connectedDevices={[ - { - agentletId: editing?.agentletId ?? 'device-1', - hostname: 'Test device', - platform: 'linux', - arch: 'x64', - version: '1.0.0', - connectedAt: '2026-01-01T00:00:00.000Z', - profileCount: 0, - }, - ]} + connectedDevices={connectedDevices} agentletId={editing?.agentletId ?? 'device-1'} onAgentletChange={vi.fn()} onClose={onClose} @@ -256,9 +265,40 @@ describe('AgentProfileEditor', () => { alias: 'Renamed', customData: legacy.customData, }); + expect(api.preview).not.toHaveBeenCalled(); }); + it('renders human-readable device labels while retaining UUID identity details', () => { + renderEditor(); + const machine = container?.querySelector( + 'select[aria-label="settings.profileMachine"]', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain( + 'Test device: linux x64', + ); + expect(machine?.selectedOptions[0]?.textContent).toContain('device-1'); + }); + + it('maps a hostname-era Profile to the unique connected device label', () => { + renderEditor({ ...legacy, agentletId: 'legacy-host' }, agents, true, [ + { + agentletId: 'device-uuid', + displayName: 'legacy-host: linux x64', + hostname: 'legacy-host', + platform: 'linux', + arch: 'x64', + version: '1.0.0', + connectedAt: '2026-01-01T00:00:00.000Z', + profileCount: 1, + }, + ]); + + expect(container?.textContent).toContain( + 'legacy-host: linux x64 (device-uuid)', + ); + }); + it('edits custom command and cwd without changing wrapper, machine, metadata, or custom data', async () => { renderEditor({ ...legacy, revision: 9 }); input('settings.launchCommand', 'new-agent --custom'); diff --git a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx index 752e2d8f2..107be07be 100644 --- a/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx +++ b/apps/web/src/components/Settings/agent-profiles/CommandProfileForm.tsx @@ -75,6 +75,18 @@ function displayNameFor(agent: string, cwd: string) { return folder ? `${agent} (${folder})` : agent; } +function resolveConnectedDevice( + target: string, + devices: ConnectedAgentletDevice[], +): ConnectedAgentletDevice | undefined { + const exact = devices.find((device) => device.agentletId === target); + if (exact) return exact; + const hostnameMatches = devices.filter( + (device) => device.hostname === target, + ); + return hostnameMatches.length === 1 ? hostnameMatches[0] : undefined; +} + /** One capability-driven form for both structured and raw-command Profiles. */ export function CommandProfileForm({ editing, @@ -200,6 +212,12 @@ export function CommandProfileForm({ })), { value: 'custom', label: t('settings.customCommand') }, ]; + const editingDevice = editing + ? resolveConnectedDevice(editing.agentletId, connectedDevices) + : undefined; + const editingDeviceLabel = editingDevice + ? `${editingDevice.displayName} (${editingDevice.agentletId})` + : `${t('settings.agentUnavailable')} (${editing?.agentletId ?? ''})`; async function save() { if (saveDisabled) return; @@ -248,14 +266,14 @@ export function CommandProfileForm({ {editing ? ( - + ) : (