From c905906d6803894d1a262d90897abc3de99c9fac Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:26:48 -0700 Subject: [PATCH 1/3] fix(agents): suggest an unused local handoff name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check global registry names before posting a default BYO handoff card. Try numeric suffixes within the 40-character limit, preserving explicit names. Suggestions do not reserve identity; attach retains all ownership checks. Written by Sprint Impl, a Commonly agent — https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium Agent-Model: claude/claude-haiku-5-5/default --- .../unit/services/byoHandoffService.test.ts | 22 +++++++++++++ .../nativeRuntimeService.byoHandoff.test.js | 33 +++++++++++++++++++ backend/services/byoHandoffService.ts | 16 +++++++++ backend/services/nativeRuntimeService.ts | 18 +++++++++- 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/byoHandoffService.test.ts b/backend/__tests__/unit/services/byoHandoffService.test.ts index 5846f569b9..572815fba5 100644 --- a/backend/__tests__/unit/services/byoHandoffService.test.ts +++ b/backend/__tests__/unit/services/byoHandoffService.test.ts @@ -5,6 +5,7 @@ import { buildByoHandoffCommand, buildByoHandoffPayload, defaultByoHandoffName, + availableByoHandoffName, } from '../../../services/byoHandoffService'; describe('byoHandoffService', () => { @@ -84,3 +85,24 @@ describe('byoHandoffService', () => { expect(defaultByoHandoffName(undefined, 'codex')).toBe('my-codex'); }); }); + +describe('availableByoHandoffName', () => { + it('preserves an unused base and checks suffixed names at the length limit', async () => { + expect(await availableByoHandoffName('sam-claude', async () => false)).toBe('sam-claude'); + const base = 'a'.repeat(40); + const isTaken = jest.fn(async (name) => name === base || name === `${'a'.repeat(38)}-2`); + expect(await availableByoHandoffName(base, isTaken)).toBe(`${'a'.repeat(38)}-3`); + expect(isTaken).toHaveBeenCalledTimes(3); + }); + + it('bounds occupied-name lookups and fails rather than suggesting an occupied name', async () => { + const isTaken = jest.fn(async () => true); + await expect(availableByoHandoffName('sam-claude', isTaken)).rejects.toThrow('Supply a different name'); + expect(isTaken).toHaveBeenCalledTimes(100); + }); + + it('propagates lookup failure instead of treating it as availability', async () => { + await expect(availableByoHandoffName('sam-claude', async () => { throw new Error('unavailable'); })) + .rejects.toThrow('unavailable'); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index f56b103848..4486ea63f5 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -10,6 +10,10 @@ jest.mock('../../../models/Pod', () => ({ findById: (...args) => mockPodFindById(...args), })); jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +const mockRegistryExists = jest.fn(); +jest.mock('../../../models/AgentRegistry', () => ({ + AgentRegistry: { exists: (...args) => mockRegistryExists(...args) }, +})); const mockUserFindOne = jest.fn(); const mockUserFindById = jest.fn(); jest.mock('../../../models/User', () => ({ @@ -84,6 +88,7 @@ describe('commonly_offer_local_agent', () => { const savedEnv = { ...process.env }; beforeEach(() => { jest.clearAllMocks(); + mockRegistryExists.mockResolvedValue(null); process.env.LITELLM_BASE_URL = 'http://litellm.test'; process.env.LITELLM_MASTER_KEY = 'test-key'; process.env.HOSTED_ENGINE = 'native'; @@ -124,6 +129,34 @@ describe('commonly_offer_local_agent', () => { expect(posted.authorKind).toBe('kernel'); }); + test('suggests a free name when the returning owner already has their default agent', async () => { + mockRegistryExists.mockImplementation(async ({ agentName }) => ( + ['sam-xu-claude', 'sam-xu-claude-2'].includes(agentName) ? { _id: agentName } : null + )); + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac' })) + .mockResolvedValueOnce(says('Run the card.')); + await runAgent(install, ask); + const posted = AgentMessageService.postMessage.mock.calls[0][0]; + expect(posted.payload.agentName).toBe('sam-xu-claude-3'); + for (const command of Object.values(posted.payload.commands)) { + expect(command).toContain('--name sam-xu-claude-3 --memory-from scout'); + expect(command).toContain('commonly agent run sam-xu-claude-3'); + } + expect(mockRegistryExists.mock.calls.map(([query]) => query)).toEqual([ + { agentName: 'sam-xu-claude' }, { agentName: 'sam-xu-claude-2' }, { agentName: 'sam-xu-claude-3' }, + ]); + }); + + test('keeps an explicitly chosen name and leaves ownership decisions to attach', async () => { + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac', name: 'chosen-local' })) + .mockResolvedValueOnce(says('Run the card.')); + await runAgent(install, ask); + expect(AgentMessageService.postMessage.mock.calls[0][0].payload.agentName).toBe('chosen-local'); + expect(mockRegistryExists).not.toHaveBeenCalled(); + }); + test('keeps a broker result from crossing a bridge before the handoff card', async () => { mockUserFindOne.mockReturnValue(chain({ _id: 'seat-1' })); mockPodFindById.mockReturnValue(chain({ name: 'Build Pod', members: ['seat-1'] })); diff --git a/backend/services/byoHandoffService.ts b/backend/services/byoHandoffService.ts index db5b8dc242..1dcb2c5bcb 100644 --- a/backend/services/byoHandoffService.ts +++ b/backend/services/byoHandoffService.ts @@ -138,3 +138,19 @@ export const defaultByoHandoffName = (username: unknown, adapter: ByoHandoffAdap const base = safeName(username) || 'my'; return `${base}-${adapter}`.slice(0, 40).replace(/-+$/g, '') || `my-${adapter}`; }; + +/** Suggestions do not reserve names; attach must still enforce ownership. */ +export const availableByoHandoffName = async ( + base: string, + isTaken: (name: string) => Promise, +): Promise => { + for (let attempt = 1; attempt <= 100; attempt += 1) { + const suffix = attempt === 1 ? '' : `-${attempt}`; + const candidate = `${base.slice(0, 40 - suffix.length).replace(/-+$/g, '')}${suffix}`; + // Check the global registry, including other owners and uninstalled names. + // Reusing an owned name can still collide with that agent's existing memory. + // eslint-disable-next-line no-await-in-loop + if (!await isTaken(candidate)) return candidate; + } + throw new Error('No free local agent name found. Supply a different name.'); +}; diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5b9fa75438..80e8c51e81 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -54,6 +54,7 @@ import { import { buildByoHandoffPayload, defaultByoHandoffName, + availableByoHandoffName, type ByoHandoffAdapter, } from './byoHandoffService'; import { appendLongTermMemory } from './agentMemoryService'; @@ -934,7 +935,7 @@ export async function dispatchTool( const agentName = args.name ? String(args.name) : defaultByoHandoffName(owner?.username, adapter); - const payload = buildByoHandoffPayload({ + let payload = buildByoHandoffPayload({ adapter, os: args.os, podId: ctx.podId, @@ -945,6 +946,21 @@ export async function dispatchTool( if ('error' in payload) { return { content: { ok: false, error: payload.error }, error: 'handoff_invalid' }; } + if (!args.name) { + const { AgentRegistry } = require('../models/AgentRegistry'); + const availableName = await availableByoHandoffName( + payload.agentName, + async (name) => Boolean(await AgentRegistry.exists({ agentName: name })), + ); + const availablePayload = buildByoHandoffPayload({ + adapter, os: args.os, podId: ctx.podId, agentName: availableName, + memoryFrom: ctx.agentName, memoryInstance: ctx.instanceId || 'default', + }); + if ('error' in availablePayload) { + return { content: { ok: false, error: availablePayload.error }, error: 'handoff_invalid' }; + } + payload = availablePayload; + } const AgentMessageService = require('./agentMessageService'); const result = await AgentMessageService.postMessage({ agentName: ctx.agentName, From 062b69408bdac8b75a804b13c8893e28bc9eca17 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:01:09 -0700 Subject: [PATCH 2/3] fix(agents): post the handoff card when the free-name lookup fails A registry lookup error or an exhausted candidate list threw before the card was posted, so the pod saw nothing. Keep the default name in that case; attach still enforces ownership on it. Adds a test that a lookup rejection still posts the card. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: sprint-impl Agent-Model: claude/claude-haiku-5-5/default --- .../nativeRuntimeService.byoHandoff.test.js | 17 ++++++++++++ backend/services/nativeRuntimeService.ts | 27 ++++++++++--------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index 4486ea63f5..ee408ae3a4 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -148,6 +148,23 @@ describe('commonly_offer_local_agent', () => { ]); }); + test('still posts the handoff card with the default name when the registry lookup fails', async () => { + mockRegistryExists.mockRejectedValue(new Error('registry unavailable')); + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac' })) + .mockResolvedValueOnce(says('Run the card.')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await runAgent(install, ask); + } finally { + warn.mockRestore(); + } + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + const posted = AgentMessageService.postMessage.mock.calls[0][0]; + expect(posted.messageType).toBe('card'); + expect(posted.payload.agentName).toBe('sam-xu-claude'); + }); + test('keeps an explicitly chosen name and leaves ownership decisions to attach', async () => { axios.post .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac', name: 'chosen-local' })) diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 80e8c51e81..7b7ffa2af9 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -947,19 +947,22 @@ export async function dispatchTool( return { content: { ok: false, error: payload.error }, error: 'handoff_invalid' }; } if (!args.name) { - const { AgentRegistry } = require('../models/AgentRegistry'); - const availableName = await availableByoHandoffName( - payload.agentName, - async (name) => Boolean(await AgentRegistry.exists({ agentName: name })), - ); - const availablePayload = buildByoHandoffPayload({ - adapter, os: args.os, podId: ctx.podId, agentName: availableName, - memoryFrom: ctx.agentName, memoryInstance: ctx.instanceId || 'default', - }); - if ('error' in availablePayload) { - return { content: { ok: false, error: availablePayload.error }, error: 'handoff_invalid' }; + // Fail toward posting: if the lookup fails or every candidate is taken, + // keep the default name. Attach still enforces ownership on that name. + try { + const { AgentRegistry } = require('../models/AgentRegistry'); + const availableName = await availableByoHandoffName( + payload.agentName, + async (name) => Boolean(await AgentRegistry.exists({ agentName: name })), + ); + const availablePayload = buildByoHandoffPayload({ + adapter, os: args.os, podId: ctx.podId, agentName: availableName, + memoryFrom: ctx.agentName, memoryInstance: ctx.instanceId || 'default', + }); + if (!('error' in availablePayload)) payload = availablePayload; + } catch (err) { + console.warn('[byo-handoff] free-name lookup failed; posting default name', (err as Error).message); } - payload = availablePayload; } const AgentMessageService = require('./agentMessageService'); const result = await AgentMessageService.postMessage({ From ff95a6cce8dd2b31916d8546f21ce76ecbf1fd1e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:22:11 -0700 Subject: [PATCH 3/3] fix(agents): bound the BYO free-name lookup so a hung registry read still posts the card The fallback only caught rejections. A registry read that never settles kept availableByoHandoffName pending, so the handoff card never posted. Race the lookup against a 3s deadline; a timeout takes the existing default-name path. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: sprint-impl Agent-Model: claude/claude-haiku-5-5/default --- .../nativeRuntimeService.byoHandoff.test.js | 15 +++++++++++++++ backend/services/nativeRuntimeService.ts | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index ee408ae3a4..c69891ca9c 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -165,6 +165,21 @@ describe('commonly_offer_local_agent', () => { expect(posted.payload.agentName).toBe('sam-xu-claude'); }); + test('posts the handoff card with the default name when a registry lookup never settles', async () => { + mockRegistryExists.mockReturnValue(new Promise(() => {})); + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac' })) + .mockResolvedValueOnce(says('Run the card.')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await runAgent(install, ask); + } finally { + warn.mockRestore(); + } + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage.mock.calls[0][0].payload.agentName).toBe('sam-xu-claude'); + }, 15000); + test('keeps an explicitly chosen name and leaves ownership decisions to attach', async () => { axios.post .mockResolvedValueOnce(callsTool('commonly_offer_local_agent', { adapter: 'claude', os: 'mac', name: 'chosen-local' })) diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 7b7ffa2af9..73ae2163f6 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -125,6 +125,17 @@ const NATIVE_RUNTIME_GUARDRAILS = ( // How a guardrail rejection reads in a chat call's error body. const GUARDRAIL_REJECTION = /guardrail|moderation|prompt-injection/i; const GUARD_CHECK_TIMEOUT_MS = 10_000; +// The free-name walk is advisory; past this it gives up and the default name posts. +const BYO_NAME_LOOKUP_TIMEOUT_MS = 3_000; +// Rejects if `work` has not settled in time, so a hung registry read cannot +// keep the handoff card from posting. +const withDeadline = (work: Promise, ms: number): Promise => { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('name lookup timed out')), ms); + }); + return Promise.race([work, deadline]).finally(() => clearTimeout(timer)); +}; // A web result the guard stops on its own is replaced with this before the // turn's one extra round. It names no rule and repeats nothing from the result. @@ -951,10 +962,10 @@ export async function dispatchTool( // keep the default name. Attach still enforces ownership on that name. try { const { AgentRegistry } = require('../models/AgentRegistry'); - const availableName = await availableByoHandoffName( + const availableName = await withDeadline(availableByoHandoffName( payload.agentName, async (name) => Boolean(await AgentRegistry.exists({ agentName: name })), - ); + ), BYO_NAME_LOOKUP_TIMEOUT_MS); const availablePayload = buildByoHandoffPayload({ adapter, os: args.os, podId: ctx.podId, agentName: availableName, memoryFrom: ctx.agentName, memoryInstance: ctx.instanceId || 'default',