From df8a470def406aaf6ba5a450db761f8dbd700ff4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:22:16 -0700 Subject: [PATCH] feat(first-session): Scout answers "can Commonly host it?" with a Run it here button, and quotes the limits the meter enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA 2026-10-16 (fresh account, production f8b43625): asked "I'd rather not install anything. Can Commonly host it for me?", Scout replied with text directions and no link ("Go to Agents → Bring your own → Run it here"), while the bring-your-own path gets a card. It also said "200 turns a day"; the card, the Running panel and /api/hosted/availability all say 30, because #2237 changed the plan line and Scout's prompt kept its own copy of the old one. - New opt-in native tool commonly_offer_hosted_agent (Scout declares it). It posts the model's reply with one server-composed start button, "Run it here", to /v2/agents/byo?pod=&mode=hosted. The href is built server-side, never by the model, and the thread already renders in-app /v2/ hrefs (#2218). - The tool is offered only where the instance can host (hostedEngineAvailable, now the one rule the hosted routes use too). If the installer already holds the beta's hosted agents it posts nothing and names the agent, so Scout never shows a button that ends in the install route's 403. - The limits reach the model only through the tool's description, built from hostedCaps() (free and Pro) when the module loads. Scout's prompt states no number, and a test fails if it ever does again. - Bring your own reads ?mode=hosted and opens on "Run it here", which counts only once /api/hosted/availability says the instance can host. Bare visits still preselect nothing (ruled 2026-10-14), and ?name= still opens on Bring your own runtime. - The tool-loop marks the offer as the turn's post, so the fallback never posts Scout's narration beside the button. Existing Scout installs carry a copy of the prompt and tool list; after deploy, refresh them with refresh-native-agent-configs --app scout --apply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../nativeAgents.scoutKnowledge.test.js | 10 ++ backend/__tests__/unit/routes/hosted.test.js | 2 + .../nativeRuntimeService.hostedOffer.test.js | 133 ++++++++++++++++++ .../nativeRuntimeService.toolGating.test.js | 61 +++++++- backend/config/native-agents/scout.ts | 13 +- backend/routes/hosted.ts | 9 +- backend/services/hostedRuntimeService.ts | 3 +- backend/services/nativeRuntimeService.ts | 113 ++++++++++++++- .../v2/__tests__/V2AgentBYOHosted.test.tsx | 59 ++++++++ frontend/src/v2/components/V2AgentBYO.tsx | 18 ++- 10 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js diff --git a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js index 026ec654c5..df9cf73559 100644 --- a/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js +++ b/backend/__tests__/unit/config/nativeAgents.scoutKnowledge.test.js @@ -77,4 +77,14 @@ describe('Scout onboarding knowledge', () => { expect(prompt).toMatch(/at most one question per turn/); expect(prompt).not.toMatch(/within the hour|within an hour|you will hear back|you'll hear/i); }); + + // QA 2026-10-16: Scout said "200 turns a day" while the card and the meter + // said 30, because the prompt kept its own copy of the plan line. The limits + // now reach Scout only through the hosted offer's description. + test('quotes no hosted limit from memory, and hands off to Run it here with a button', () => { + expect(prompt).not.toMatch(/\d+\s+turns (a|per) day/i); + expect(prompt).toContain('commonly_offer_hosted_agent'); + expect(scoutApp.tools).toContain('commonly_offer_hosted_agent'); + expect(prompt).toMatch(/with no invitation code/); + }); }); diff --git a/backend/__tests__/unit/routes/hosted.test.js b/backend/__tests__/unit/routes/hosted.test.js index a09d7ccc57..f8ef8f6d66 100644 --- a/backend/__tests__/unit/routes/hosted.test.js +++ b/backend/__tests__/unit/routes/hosted.test.js @@ -63,6 +63,8 @@ jest.mock('../../../services/hostedRuntimeService', () => mockHosted); const mockNativeAvailable = jest.fn(() => false); jest.mock('../../../services/nativeRuntimeService', () => ({ isNativeRuntimeAvailable: (...args) => mockNativeAvailable(...args), + // The real rule, over this file's two switches. + hostedEngineAvailable: (engine) => (engine === 'native' ? mockNativeAvailable() : mockHosted.isConfigured()), })); const mockMarkByoOpened = jest.fn(); jest.mock('../../../services/onboardingMarks', () => ({ markByoOpened: (...args) => mockMarkByoOpened(...args) })); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js new file mode 100644 index 0000000000..3dc2c0f3b3 --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js @@ -0,0 +1,133 @@ +/** + * Scout's hosted offer (QA 2026-10-16): asked "can Commonly host it?", Scout + * answered with text directions and no link, while the Bring-your-own path got + * a card. commonly_offer_hosted_agent posts the model's reply with one + * server-composed button to Run it here, aimed at this pod. + */ +jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock('../../../models/Pod', () => ({ + findById: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ name: 'My Workspace' }) })), +})); +jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +jest.mock('../../../models/User', () => ({ + findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), +})); +jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); +jest.mock('../../../services/messageClaimService', () => ({ + claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), + release: jest.fn().mockResolvedValue({ released: true }), +})); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); +const mockListHosted = jest.fn(); +jest.mock('../../../services/hostedRuntimeService', () => ({ + ...jest.requireActual('../../../services/hostedRuntimeService'), + listHostedInstallationsForUser: (...args) => mockListHosted(...args), +})); + +const axios = require('axios').default; +const AgentRun = require('../../../models/AgentRun'); +const AgentMessageService = require('../../../services/agentMessageService'); +const { runAgent } = require('../../../services/nativeRuntimeService'); + +const POD = '507f1f77bcf86cd799439011'; +const OWNER = '507f1f77bcf86cd799439099'; +const scoutInstall = { + podId: POD, + agentName: 'scout', + instanceId: 'u1234567890', + displayName: 'Scout', + installedBy: OWNER, + config: { runtime: { runtimeType: 'native' }, tools: ['commonly_post_message', 'commonly_offer_hosted_agent'] }, +}; +const ask = { + type: 'chat.mention', + eventId: '507f1f77bcf86cd799439012', + payload: { content: 'I\'d rather not install anything. Can Commonly host it for me?' }, +}; +const REPLY = 'Yes. Commonly can run it for you, nothing to install.'; + +const callsTool = (name, args) => ({ + status: 200, + headers: {}, + data: { + choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: JSON.stringify(args) } }] } }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +}); +const says = (content) => ({ + status: 200, + headers: {}, + data: { choices: [{ message: { role: 'assistant', content } }], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } }, +}); + +describe('commonly_offer_hosted_agent', () => { + const savedEnv = { ...process.env }; + beforeEach(() => { + jest.clearAllMocks(); + process.env.LITELLM_BASE_URL = 'http://litellm.test'; + process.env.LITELLM_MASTER_KEY = 'test-key'; + process.env.HOSTED_ENGINE = 'native'; + AgentRun.countDocuments.mockResolvedValue(0); + AgentRun.create.mockImplementation(async (doc) => ({ _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) })); + mockListHosted.mockResolvedValue([]); + }); + afterAll(() => { process.env = savedEnv; }); + + test('posts the reply once, with one Run it here button aimed at this pod with hosting chosen', async () => { + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('Posted the button above.')); + + const result = await runAgent(scoutInstall, ask); + + expect(result.status).toBe('succeeded'); + expect(mockListHosted).toHaveBeenCalledWith(OWNER); + // The narration after the tool is not posted beside it. + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + const posted = AgentMessageService.postMessage.mock.calls[0][0]; + expect(posted.content).toBe(REPLY); + expect(posted.podId).toBe(POD); + expect(posted.payload).toEqual({ + kind: 'starts', + starts: [{ id: 'run-hosted', label: 'Run it here', href: `/v2/agents/byo?pod=${POD}&mode=hosted` }], + }); + // The thread renders a start's href only as an in-app /v2/ path. + expect(posted.payload.starts[0].href).toMatch(/^\/v2\/[^/\\]/); + }); + + test('posts nothing when the person already holds the beta\'s hosted agent, and names it', async () => { + mockListHosted.mockResolvedValue([{ agentName: 'lee-agent', instanceId: 'default', podId: POD }]); + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('You already have lee-agent. Mention it here.')); + + await runAgent(scoutInstall, ask); + + const toolMessage = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(toolMessage.content).toMatch(/already have 1 hosted agent/); + expect(toolMessage.content).toContain('lee-agent'); + // Only the model's own follow-up reached the pod, with no button. + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage.mock.calls[0][0].payload).toBeUndefined(); + }); + + test('is not offered where the instance cannot host, so no dead button is posted', async () => { + delete process.env.HOSTED_ENGINE; + delete process.env.HOSTED_RUNTIME_URL; + delete process.env.HOSTED_RUNTIME_ADMIN_TOKEN; + axios.post + .mockResolvedValueOnce(callsTool('commonly_offer_hosted_agent', { content: REPLY })) + .mockResolvedValueOnce(says('Hosting is not available here.')); + + await runAgent(scoutInstall, ask); + + const offered = axios.post.mock.calls[0][1].tools.map((tool) => tool.function.name); + expect(offered).not.toContain('commonly_offer_hosted_agent'); + const toolMessage = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(toolMessage.content).toMatch(/not available to you in this turn/); + expect(AgentMessageService.postMessage.mock.calls.every(([arg]) => arg.payload === undefined)).toBe(true); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js index 9a78925494..252ae3871c 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js @@ -6,10 +6,37 @@ * filters exactly; an undeclared (legacy) config keeps the pre-gate surface * MINUS approval proposing, which is opt-in by declaration only. */ -const { toolsForConfig } = require('../../../services/nativeRuntimeService'); +const { toolsForConfig, TOOLS } = require('../../../services/nativeRuntimeService'); +const { hostedCaps } = require('../../../services/hostedRuntimeService'); const names = (tools) => tools.map((t) => t.function.name); +// Runs fn with these env vars set (undefined deletes), then restores them. +const withEnv = (vars, fn) => { + const saved = Object.fromEntries(Object.keys(vars).map((k) => [k, process.env[k]])); + try { + Object.entries(vars).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + return fn(); + } finally { + Object.entries(saved).forEach(([k, v]) => { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + }); + } +}; +const NATIVE_HOSTING = { HOSTED_ENGINE: 'native', LITELLM_BASE_URL: 'http://litellm.test', LITELLM_MASTER_KEY: 'k' }; +const NO_HOSTING = { + HOSTED_ENGINE: undefined, + HOSTED_RUNTIME_URL: undefined, + HOSTED_RUNTIME_ADMIN_TOKEN: undefined, + LITELLM_BASE_URL: undefined, + LITELLM_MASTER_KEY: undefined, + LITELLM_API_KEY: undefined, +}; + describe('toolsForConfig', () => { test('a declared allowlist filters exactly', () => { const tools = toolsForConfig({ tools: ['commonly_post_message', 'commonly_propose_action'] }); @@ -29,13 +56,43 @@ describe('toolsForConfig', () => { test('the scout manifest grants propose_action', () => { // eslint-disable-next-line global-require const { scoutApp } = require('../../../config/native-agents/scout'); - const tools = toolsForConfig({ tools: [...scoutApp.tools] }); + // On an instance that can host, so the hosted offer is offered too. + const tools = withEnv(NATIVE_HOSTING, () => toolsForConfig({ tools: [...scoutApp.tools] })); expect(names(tools)).toContain('commonly_propose_action'); // The manifest and the runtime's schema must agree — a declared tool with // no schema would silently vanish here. expect(tools.length).toBe(scoutApp.tools.length); }); + test('the hosted offer is opt-in, and offered only where the instance can host', () => { + const declared = { tools: ['commonly_offer_hosted_agent'] }; + withEnv(NO_HOSTING, () => { + expect(names(toolsForConfig(declared))).not.toContain('commonly_offer_hosted_agent'); + }); + withEnv(NATIVE_HOSTING, () => { + expect(names(toolsForConfig(declared))).toContain('commonly_offer_hosted_agent'); + // Never on the default surface, even where hosting works. + expect(names(toolsForConfig({}))).not.toContain('commonly_offer_hosted_agent'); + expect(names(toolsForConfig({ tools: ['commonly_post_message'] }))).not.toContain('commonly_offer_hosted_agent'); + }); + // The Durable Object engine counts when its worker is configured. + withEnv({ ...NO_HOSTING, HOSTED_RUNTIME_URL: 'https://worker.test', HOSTED_RUNTIME_ADMIN_TOKEN: 't' }, () => { + expect(names(toolsForConfig(declared))).toContain('commonly_offer_hosted_agent'); + }); + }); + + // Scout told strangers "200 turns a day" after the free line became 30 + // (#2237): the number lived in a copy. The offer's description is built from + // the cap source, so the model is told the line the meter enforces. + test('the hosted offer states the limits the meter enforces', () => { + const offer = TOOLS.find((t) => t.function.name === 'commonly_offer_hosted_agent'); + const free = hostedCaps(); + const pro = hostedCaps({ pro: true }); + expect(offer.function.description).toContain(`${free.turnsPerDay} turns a day (${pro.turnsPerDay} on Pro)`); + expect(offer.function.description).toContain(`${free.agentsPerUser} hosted agent${free.agentsPerUser === 1 ? '' : 's'} per account`); + expect(offer.function.description).toMatch(/no invitation code/); + }); + test('agent_status is declaration-gated like every other tool', () => { // Scout declares it; a legacy config or a minimal allowlist does not // receive it implicitly. diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index a5a0de97c2..687f11b70d 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -69,8 +69,11 @@ export const scoutApp = { + 'your own" button (or "Set it up" on the "Your own agent" card) opens ' + 'the "Bring your own agent" page (/v2/agents/byo). It offers:\n' + ' * "Run it here": Commonly hosts the agent. Open to every signed-in user ' - + 'during beta, one hosted agent per account and 200 turns a day. Nothing to ' - + 'install; it is live in about ten seconds.\n' + + 'during beta, with no invitation code. Nothing to install; it is live a few ' + + 'seconds after the click. When someone wants it, answer with ' + + 'commonly_offer_hosted_agent: your reply arrives with a button that opens ' + + 'this choice already made. Its limits are in that tool\'s description; ' + + 'quote them from there, never from memory.\n' + ' * "Bring your own runtime": their own Claude Code or Codex, on their own ' + 'machine. The page issues a token once and shows these commands, which ' + 'are the whole setup:\n' @@ -94,7 +97,7 @@ export const scoutApp = { + 'terminal.\n' + '- The commands above are verified on macOS. Windows is not verified yet ' + '(the export lines are bash, and the issue is open): on Windows say so ' - + 'plainly and offer "Run it here" instead.\n' + + 'plainly and offer "Run it here" instead, with commonly_offer_hosted_agent.\n' + '- The listener runs Claude Code or Codex. Do not claim it runs any other ' + 'tool.\n' + '- Developers building their own agent: Commonly agents use a small HTTP ' @@ -154,6 +157,8 @@ export const scoutApp = { + 'once, without narrating it.\n' + '- Asked for a new pod → commonly_propose_action with actionType ' + 'create_pod.\n' + + '- They want Commonly to run an agent for them, or would rather not ' + + 'install anything → commonly_offer_hosted_agent.\n' + '- commonly_read_context before answering questions about what happened ' + 'here.', 'Never claim the user\'s own agent is connected or working — the ' @@ -173,6 +178,8 @@ export const scoutApp = { // via approval card only. 'commonly_propose_action', 'commonly_agent_status', + // Answers "can Commonly host it?" with a button to Run it here. + 'commonly_offer_hosted_agent', ], iconUrl: '', categories: ['onboarding', 'utility'], diff --git a/backend/routes/hosted.ts b/backend/routes/hosted.ts index bf3b9b23c8..4988ba4e13 100644 --- a/backend/routes/hosted.ts +++ b/backend/routes/hosted.ts @@ -22,7 +22,7 @@ const { AgentInstallation } = require('../models/AgentRegistry'); const User = require('../models/User'); const AgentIdentityService = require('../services/agentIdentityService'); const hostedRuntime = require('../services/hostedRuntimeService'); -const { isNativeRuntimeAvailable } = require('../services/nativeRuntimeService'); +const { hostedEngineAvailable, isNativeRuntimeAvailable } = require('../services/nativeRuntimeService'); const { HOSTED_ENGINE_NATIVE, engineForNewHostedInstall, @@ -145,11 +145,10 @@ const resolveOwnedHostedInstallation = async (req: any, source: Record ( - engine === HOSTED_ENGINE_NATIVE ? isNativeRuntimeAvailable() : hostedRuntime.isConfigured() -); +const engineAvailable = (engine: string): boolean => hostedEngineAvailable(engine); /** * Refuses before any database read when no engine can run on this instance. diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index 0f56649bdb..e9e5c836b9 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -9,7 +9,8 @@ * makes an unmetered public runtime safe to expose: * * HOSTED_AGENTS_PER_USER active hosted installations per owner (default 1) - * HOSTED_TURNS_PER_DAY acked events per hosted agent per UTC day (200) + * HOSTED_TURNS_PER_DAY acked events per hosted agent per UTC day (30; + * HOSTED_TURNS_PER_DAY_PRO, 200, on Pro) * * The daily cap is a RATE LIMIT ON REQUESTS, not a spend meter: it counts * acked AgentEvents by ack time (`deliveredAt`, which ack() stamps). One diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index eff7beb1b0..d2c63c7845 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -36,8 +36,12 @@ import { podIsBridged } from './outsiderGrantPodEligibility'; import { isSandboxConfigured, TurnSandbox } from './turnSandboxService'; import { isSearchConfigured, searchWeb } from './webSearchService'; import { untrustedConnectorMarker } from './brokerResultMarker'; -import { hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative } from './hostedEngine'; -import { hostedCapsForOwner, ownerIsPro } from './hostedRuntimeService'; +import { + engineForNewHostedInstall, HOSTED_ENGINE_NATIVE, hostedNativeDecline, hostedNativeRunConfig, hostedRunsNative, +} from './hostedEngine'; +import { + hostedCaps, hostedCapsForOwner, isConfigured as hostedWorkerConfigured, listHostedInstallationsForUser, ownerIsPro, +} from './hostedRuntimeService'; // --- public surface -------------------------------------------------------- @@ -120,6 +124,25 @@ export function isNativeRuntimeAvailable(): boolean { return resolveLiteLLM() !== null; } +/** + * Whether a hosted agent on `engine` can run on this instance. The hosted + * routes and Scout's hosted offer both ask, so the offer never shows a button + * the page would refuse. + */ +export function hostedEngineAvailable(engine: string = engineForNewHostedInstall()): boolean { + return engine === HOSTED_ENGINE_NATIVE ? isNativeRuntimeAvailable() : hostedWorkerConfigured(); +} + +// What the hosted offer's tool description tells the model. Read from the cap +// source when this module loads, so no prompt holds its own copy of a limit +// (Scout quoted 200 turns a day after the free line became 30, #2237). +const HOSTED_FREE_CAPS = hostedCaps(); +const HOSTED_PRO_CAPS = hostedCaps({ pro: true }); +export const HOSTED_LIMITS_LINE = `${HOSTED_FREE_CAPS.agentsPerUser} hosted ` + + `agent${HOSTED_FREE_CAPS.agentsPerUser === 1 ? '' : 's'} per account and ` + + `${HOSTED_FREE_CAPS.turnsPerDay} turns a day (${HOSTED_PRO_CAPS.turnsPerDay} on Pro)`; +export const HOSTED_OFFER_LABEL = 'Run it here'; + // --- tool schema (OpenAI function-calling format) ------------------------- // // SOURCE OF TRUTH for the `internal` runtime's tool names. `CommonlyTool` @@ -261,6 +284,32 @@ export const TOOLS = [ parameters: { type: 'object', properties: {} }, }, }, + { + type: 'function', + function: { + name: 'commonly_offer_hosted_agent', + description: + 'Answer someone who wants Commonly to run an agent for them: nothing to install, no ' + + 'machine of their own, or on Windows. Posts your content to the pod with a ' + + `"${HOSTED_OFFER_LABEL}" button that opens Bring your own with hosting already chosen; ` + + 'one more click there and their agent is live in a few seconds. Open to every ' + + 'signed-in account during beta, no invitation code needed. The current limits are ' + + `${HOSTED_LIMITS_LINE}; quote these, never a number from memory. The content is your ` + + 'whole reply: do not post another message about it. If they already have a hosted ' + + 'agent, or this instance cannot host, nothing is posted and the result says why; ' + + 'tell them in your own words.', + parameters: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'Your reply, in the person\'s language. The button follows it.', + }, + }, + required: ['content'], + }, + }, + }, { type: 'function', function: { @@ -304,12 +353,14 @@ export const TOOLS = [ ] as const; // Tools an install gets only by declaring them: proposing an approval, -// running code (TASK-266), which also needs a sandbox key on the instance, and -// web search (TASK-267a), which needs a search key. +// offering hosting (Scout's), running code (TASK-266), which also needs a +// sandbox key on the instance, and web search (TASK-267a), which needs a +// search key. const OPT_IN_TOOLS: ReadonlySet = new Set([ - 'commonly_propose_action', 'commonly_run_code', 'commonly_web_search', + 'commonly_propose_action', 'commonly_offer_hosted_agent', 'commonly_run_code', 'commonly_web_search', ]); const toolAvailable = (name: string): boolean => { + if (name === 'commonly_offer_hosted_agent') return hostedEngineAvailable(); if (name === 'commonly_run_code') return isSandboxConfigured(); if (name === 'commonly_web_search') return isSearchConfigured(); return true; @@ -471,6 +522,50 @@ async function dispatchTool( return { content: { ok: true, posted: true, result } }; } + case 'commonly_offer_hosted_agent': { + // The answer and its button in one message. The button is composed + // here, never by the model: an in-app path to Bring your own aimed at + // this pod with hosting chosen (V2AgentBYO reads ?mode=hosted). + const content = String(args.content || '').trim(); + if (!content) { + return { content: { ok: false, error: 'content is required' }, error: 'missing_content' }; + } + // The person Scout serves is whoever installed it. If they already + // hold the beta's hosted agents, a button would only lead to the + // install route's 403, so say who holds the slot instead. + const { agentsPerUser } = hostedCaps(); + const held = ctx.installation?.installedBy + ? await listHostedInstallationsForUser(ctx.installation.installedBy) + : []; + if (held.length >= agentsPerUser) { + return { + content: { + ok: false, + error: `They already have ${held.length} hosted agent${held.length === 1 ? '' : 's'}, ` + + `the most the beta allows (${agentsPerUser}). Mention it to use it, or remove it on ` + + 'the Agents page to host a different one.', + hostedAgents: held.map((h) => h.agentName), + }, + error: 'hosted_cap_reached', + }; + } + const href = `/v2/agents/byo?pod=${encodeURIComponent(String(ctx.podId))}&mode=hosted`; + const AgentMessageService = require('./agentMessageService'); + const result = await AgentMessageService.postMessage({ + agentName: ctx.agentName, + instanceId: ctx.instanceId, + podId: ctx.podId, + content, + messageType: 'text', + displayName: ctx.displayName, + installationConfig: ctx.installationConfig, + metadata: { source: 'native-runtime' }, + payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: HOSTED_OFFER_LABEL, href }] }, + noOutboundRelay: Boolean(ctx.webTainted), + }); + return { content: { ok: true, posted: true, result } }; + } + case 'commonly_create_task': { const title = String(args.title || '').trim(); if (!title) { @@ -1441,6 +1536,14 @@ export async function runAgent( if (toolName === 'commonly_propose_action' && !result.error) { postedViaTool = true; } + // The hosted offer posts the model's own reply with its button, so + // the same holds: the fallback must not post the narration twice. + if (toolName === 'commonly_offer_hosted_agent' && !result.error) { + postedViaTool = true; + finalMessage = typeof (parsedArgs as any)?.content === 'string' + ? String((parsedArgs as any).content) + : undefined; + } messages.push({ role: 'tool', tool_call_id: tc.id, diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index 10f135addb..7f6edc5078 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -159,6 +159,65 @@ describe('V2AgentBYO — Run it here', () => { window.history.pushState({}, '', '/'); }); + // Scout's hosted offer links here with ?mode=hosted: the person asked + // Commonly to host their agent, so the page opens on "Run it here" and its + // act is the next click (QA 2026-10-16: the hand-off was text only). + const renderAt = (url) => { + window.history.pushState({}, '', url); + return render( + + + + + , + ); + }; + + test('a hosted offer link (?mode=hosted) opens on Run it here, one click from live', async () => { + mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); + axios.post.mockResolvedValue({ data: { ok: true } }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true')); + expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByRole('button', { name: /^run it here$/i })); + + await screen.findByTestId('byo-hosted-result'); + const installCall = axios.post.mock.calls.find(([url]) => url === '/api/registry/install'); + expect(installCall[1]).toMatchObject({ agentName: 'sam-agent', podId: 'p1', config: { runtime: { runtimeType: 'hosted' } } }); + window.history.pushState({}, '', '/'); + }); + + test('a hosted offer link offers no act until the instance says it can host', async () => { + let answerAvailability; + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/hosted/availability')) { + return new Promise((resolve) => { answerAvailability = resolve; }); + } + if (url === '/api/pods') return Promise.resolve({ data: pods }); + return Promise.resolve({ data: {} }); + }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + const podPicker = await screen.findByRole('combobox', { name: /install into pod/i }); + await waitFor(() => expect(podPicker).toHaveValue('p1')); + expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument(); + + await act(async () => { answerAvailability({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } }); }); + expect(await screen.findByRole('button', { name: /^run it here$/i })).toBeEnabled(); + window.history.pushState({}, '', '/'); + }); + + test('a hosted offer link on an instance that cannot host is the plain BYO page', async () => { + mockGet({ configured: false }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + expect(await screen.findByRole('button', { name: /install \+ generate token/i })).toBeEnabled(); + expect(screen.queryByTestId('byo-mode-hosted')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument(); + window.history.pushState({}, '', '/'); + }); + test('a persona hire sends the persona\'s card sentence as the seat description (#1649)', async () => { mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); axios.post.mockResolvedValue({ data: { ok: true } }); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 94112e39ec..5654754eab 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -98,6 +98,9 @@ const V2AgentBYO: React.FC = () => { pod: (params.get('pod') || '').replace(/[^a-f0-9]/gi, ''), name: sanitizeAgentName(params.get('name') || ''), persona: sanitizeAgentName(params.get('persona') || ''), + // ?mode=hosted comes from Scout's "Run it here" button: the person + // already asked Commonly to host their agent. + hosted: params.get('mode') === 'hosted', }; })(); // Sam (2026-09-01): persona deep links carry context into this otherwise @@ -144,8 +147,13 @@ const V2AgentBYO: React.FC = () => { // hosting. A ?name= deep link comes from Scout's connect_local_agent card: // the person asked to connect THEIR OWN agent (e.g. Claude Code), so it // starts on "Bring your own runtime" (Growth's walk, 2026-10-14: one click - // gave a hosted agent named after their Claude Code). - const [chosenMode, setMode] = useState<'hosted' | 'byo' | 'machine' | null>(prefill.name ? 'byo' : null); + // gave a hosted agent named after their Claude Code). A ?mode=hosted link + // comes from Scout's hosted offer, where the person asked to be hosted, so + // it starts on "Run it here". + const [chosenMode, setMode] = useState<'hosted' | 'byo' | 'machine' | null>(() => { + if (prefill.hosted) return 'hosted'; + return prefill.name ? 'byo' : null; + }); const [hosted, setHosted] = useState<{ agentName: string; podId: string } | null>(null); const [hostedState, setHostedState] = useState<'starting' | 'running' | 'slow'>('starting'); // ADR-026 Phase 2: "on my computer" — install + placement request; the @@ -165,7 +173,11 @@ const V2AgentBYO: React.FC = () => { // are in, a hosting reply that never arrived well-formed reads as "cannot // host", and the machines list as empty. const choosingMode = !choicesKnown || Boolean(hosting?.configured) || machines.length > 0; - const mode = chosenMode ?? (choosingMode ? null : 'byo'); + // "Run it here" counts only once the instance says it can host: a hosted + // link must not arm a hosted install before, or without, that answer. + const mode = chosenMode === 'hosted' && !hosting?.configured + ? (choosingMode ? null : 'byo') + : chosenMode ?? (choosingMode ? null : 'byo'); // One lookup, two readers (TASK-163): the preview note and the placement // payload must name the SAME machine, so it is derived once here rather // than restated at each call site.