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
22 changes: 22 additions & 0 deletions backend/__tests__/unit/services/byoHandoffService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildByoHandoffCommand,
buildByoHandoffPayload,
defaultByoHandoffName,
availableByoHandoffName,
} from '../../../services/byoHandoffService';

describe('byoHandoffService', () => {
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -124,6 +129,66 @@ 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('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('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' }))
.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'] }));
Expand Down
16 changes: 16 additions & 0 deletions backend/services/byoHandoffService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>,
): Promise<string> => {
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.');
};
32 changes: 31 additions & 1 deletion backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import {
buildByoHandoffPayload,
defaultByoHandoffName,
availableByoHandoffName,
type ByoHandoffAdapter,
} from './byoHandoffService';
import { appendLongTermMemory } from './agentMemoryService';
Expand Down Expand Up @@ -124,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 = <T>(work: Promise<T>, ms: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, 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.
Expand Down Expand Up @@ -934,7 +946,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,
Expand All @@ -945,6 +957,24 @@ export async function dispatchTool(
if ('error' in payload) {
return { content: { ok: false, error: payload.error }, error: 'handoff_invalid' };
}
if (!args.name) {
// 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 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',
});
if (!('error' in availablePayload)) payload = availablePayload;
} catch (err) {
console.warn('[byo-handoff] free-name lookup failed; posting default name', (err as Error).message);
}
}
const AgentMessageService = require('./agentMessageService');
const result = await AgentMessageService.postMessage({
agentName: ctx.agentName,
Expand Down
Loading