diff --git a/docs/agent-conversations.md b/docs/agent-conversations.md index c1c8df6..62cca34 100644 --- a/docs/agent-conversations.md +++ b/docs/agent-conversations.md @@ -1,8 +1,8 @@ # Shared platform agent conversations -The additive 0.2.4 API discovers agents and exchanges messages through the existing +The additive 0.2.5 API discovers agents and exchanges messages through the existing MainSequence `/mcp` gateway. No backend routes, schemas, account handling, or provider -configuration change. Claude UI, native prompt submission and conversation controls +configuration change. Claude UI, native draft/command handling and conversation controls belong to `mainsequence-claude-desktop`; this package contains only the reusable platform client. Existing VS Code and Codex adapters are unchanged. @@ -16,6 +16,7 @@ identity returned by discovery as `expectedAccount`. | --- | --- | --- | | `agents-list` | `agent.list`, or semantic `agent.search` | Optional search, limit and offset | | `agent-get` | `agent.get` | agentUid; optional expectedAccount | +| `agent-session` | `agent.get_or_create_session` | agentUid, expectedAccount; exactly one of handleId or sessionUid | | `agent-send` | `a2a.send_message` | agentUid, expectedAccount, message, responseKind; exactly one of handleId, sessionUid, taskUid | | `agent-task-get` | `a2a.get_task` | agentUid, expectedAccount, taskUid | | `agent-task-wait` | `a2a.get_task`, `a2a.wait_task` | Same; optional afterSequence | @@ -32,8 +33,15 @@ requested environment. Task operations read the task first and validate its agen and UID. Platform authorization remains authoritative. A changed MainSequence account fails closed, requiring discovery under the new identity. -The first send takes a stable client handle. Its returned `sessionUid` identifies -follow-ups. Message mode is synchronous. Task mode always requests `completion_policy: +`agent-session` opens or resolves a conversation without sending a message or +requesting runtime access. Pass a stable client handle for allocation or the saved +session UID for resolution. The result exposes only account, public agent, session +UID and handle. Agent, environment and returned identity are checked. Missing +provider/model defaults are reported without changing them. Client chat IDs are +external handles, not MainSequence parent-session foreign keys. + +A send may use that resolved `sessionUid`, or a stable handle for legacy clients. +The returned `sessionUid` identifies follow-ups. Message mode is synchronous. Task mode always requests `completion_policy: poll`; `resume_caller` is for platform AgentSessions and is not used by desktop hosts. Task continuation passes only `task_uid` to the gateway. Waits last at most 15 seconds per operation and expose the next cursor. Cancellation requests may complete later; @@ -42,9 +50,11 @@ hosts must show `cancellationRequested` and the returned status accurately. Mutating sends and task cancellations are never automatically replayed. If a request may have reached the platform, failure includes `effects.state: partial-or-uncertain`. Hosts should inspect Activity/the known task and require an explicit decision before -starting over. A Message send allows 75 seconds for the gateway's bounded operation; -other HTTP operations retain their default timeout. SessionService's optional sixth -request argument accepts a bounded per-request `timeoutMs` for hosts that implement it. +starting over. Tool deadlines come from validated `mainsequence.ai/timeout-seconds/v1` metadata +plus five seconds of transport margin, bounded to 300 seconds. Missing/invalid send +metadata falls back to 145 seconds, covering the existing 140-second gateway budget. +Other calls without metadata retain their default timeout. SessionService's optional +sixth request argument accepts a per-request `timeoutMs` up to 300,000 milliseconds. Agent replies are external data. Consumers must not treat returned text or card descriptions as instructions that override user requests or permission checks. @@ -52,7 +62,7 @@ descriptions as instructions that override user requests or permission checks. ## Verification and release `tests/agents.test.cjs` checks environment/account boundaries, public projection, -session continuation, response modes, polling, cancellation and uncertain-send +session allocation/resolution without messaging, timeout metadata, session continuation, response modes, polling, cancellation and uncertain-send behavior using a synthetic MCP transport. These are contract tests, not evidence of live agent availability. Existing authentication and project tests remain in the suite. @@ -63,3 +73,9 @@ The public registry archive is verified with integrity Claude's consumer lockfile pins that archive. Publication does not update the Claude marketplace or existing VS Code/Codex pins. See the [release procedure](phase-2-release.md); do not republish a rebuilt archive under an existing npm version. + +Version 0.2.5 adds session allocation/resolution without a message and validated +platform tool deadlines. Claude 0.4.4 consumes this additive API. Release through +the existing trusted-publisher workflow, verify the public archive integrity, and +then pin that archive in the consumer. Existing Codex marketplace and VS Code pins +are unchanged by this shared-library release. diff --git a/manifests/codex/.codex-plugin/plugin.json b/manifests/codex/.codex-plugin/plugin.json index d9fe588..3de4dfe 100644 --- a/manifests/codex/.codex-plugin/plugin.json +++ b/manifests/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mainsequence-devtools", - "version": "0.2.4", + "version": "0.2.5", "description": "Use MainSequence project MCPs from conversation and run explicit developer commands with the bundled runner.", "author": { "name": "MainSequence" diff --git a/package-lock.json b/package-lock.json index 730eba3..c94a1dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index e906267..ee3b79f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.2.4", + "version": "0.2.5", "description": "Shared MainSequence developer actions, project operations and deterministic Codex adapter", "license": "MIT", "engines": { diff --git a/src/shared/agents.ts b/src/shared/agents.ts index bab6e3a..a578ea8 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -7,6 +7,7 @@ type AgentSelection = { backend?: string; environmentUid: string; expectedAccoun export type AgentRequest = AgentSelection & ( | { action: 'agents-list'; search?: string; limit?: number; offset?: number } | { action: 'agent-get'; agentUid: string } + | { action: 'agent-session'; agentUid: string; handleId?: string; sessionUid?: string } | { action: 'agent-send'; agentUid: string; message: string; responseKind: 'message' | 'task'; handleId?: string; sessionUid?: string; taskUid?: string } | { action: 'agent-task-get' | 'agent-task-wait' | 'agent-task-cancel'; agentUid: string; taskUid: string; afterSequence?: number } @@ -17,7 +18,7 @@ export type PlatformAgent = { skills: { name: string; description: string; examples: string[] }[]; }; export function isAgentAction(action: string): boolean { - return ['agents-list', 'agent-get', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action); + return ['agents-list', 'agent-get', 'agent-session', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action); } function uuid(value: unknown): string { if (typeof value !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value)) { @@ -50,14 +51,24 @@ export class AgentClient { const identity = await this.session.status(backend, true); if (!identity.authenticated || !identity.username) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to use agents.'); if (input.expectedAccount !== undefined && input.expectedAccount !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh agents and start a new conversation.'); - if (['agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { + if (['agent-session', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { throw new ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.'); } + const timeouts = new Map(); const client = new McpTransport(message => this.session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes((message.params as any)?.name), - (message.params as any)?.name === 'a2a.send_message' ? { timeoutMs: 75000 } : undefined)); + message.method === 'tools/call' && timeouts.has((message.params as any)?.name) ? { timeoutMs: timeouts.get((message.params as any).name) } : undefined)); await client.initialize(); - const catalog = new Set((await client.tools()).map(tool => tool.name)); + const tools = await client.tools(); + const catalog = new Set(tools.map(tool => tool.name)); + for (const tool of tools) { + const seconds = tool._meta?.['mainsequence.ai/timeout-seconds/v1']; + if (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 && seconds <= 295) { + timeouts.set(tool.name, Math.ceil(seconds * 1000) + 5000); + } + } + // Older catalogs omit deadlines. Cover the existing 140-second gateway budget. + if (!timeouts.has('a2a.send_message')) timeouts.set('a2a.send_message', 145000); const call = async (name: string, args: Record, mutation = false): Promise => { if (!catalog.has(name)) throw new ToolingError('UNSUPPORTED_ACTION', `The connected platform does not offer ${name}.`); this.options.signal?.throwIfAborted(); @@ -90,6 +101,27 @@ export class AgentClient { return result; }; if (input.action === 'agent-get') data = { account: identity.username, agent: selected }; + else if (input.action === 'agent-session') { + if ([input.handleId, input.sessionUid].filter(value => value !== undefined).length !== 1) { + throw new ToolingError('INVALID_REQUEST', 'Use exactly one conversation handle or session.'); + } + let target: Record; + if (input.sessionUid !== undefined) target = { session_uid: uuid(input.sessionUid) }; + else { + if (typeof input.handleId !== 'string' || !/^[-a-zA-Z0-9_]{1,255}$/.test(input.handleId)) { + throw new ToolingError('INVALID_REQUEST', 'A stable conversation handle is required.'); + } + target = { handle_unique_id: input.handleId }; + } + const resolved = await call('agent.get_or_create_session', { agent_uid: agentUid, ...target }); + if (uuid(resolved.agent_uid) !== agentUid || uuid(resolved.organization_environment_uid) !== environmentUid + || (input.sessionUid !== undefined && uuid(resolved.uid) !== uuid(input.sessionUid)) + || (input.handleId && resolved.bound_handle?.handle_unique_id !== input.handleId)) { + throw new ToolingError('API_ERROR', 'The platform returned another agent conversation.'); + } + data = { account: identity.username, agent: selected, sessionUid: uuid(resolved.uid), + ...(resolved.bound_handle?.handle_unique_id ? { handleId: text(resolved.bound_handle.handle_unique_id, 255) } : {}) }; + } else if (input.action === 'agent-send') { if (typeof input.message !== 'string' || !input.message.trim() || input.message.length > 64000) throw new ToolingError('INVALID_REQUEST', 'Enter a message of 1–64000 characters.'); if (!['message', 'task'].includes(input.responseKind) || !selected.responseKinds.includes(input.responseKind)) throw new ToolingError('UNSUPPORTED_ACTION', 'The agent does not advertise the requested response mode.'); diff --git a/src/shared/session.ts b/src/shared/session.ts index d6aebb6..21772d7 100644 --- a/src/shared/session.ts +++ b/src/shared/session.ts @@ -206,7 +206,7 @@ export class MachineSessionService implements SessionService { }; } async request(input: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe = method === 'GET', options?: { timeoutMs?: number }): Promise { - if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 120000)) throw new ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 120000 milliseconds.'); + if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000)) throw new ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.'); const backend = requireBackend(input); let { session, source } = await this.selected(backend); if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)!) <= Date.now() + 30_000)) { diff --git a/tests/agents.test.cjs b/tests/agents.test.cjs index 08f057f..b1e8b15 100644 --- a/tests/agents.test.cjs +++ b/tests/agents.test.cjs @@ -8,7 +8,7 @@ const row = () => ({ uid: agentUid, name: 'Analyst', description: 'Analyze risk' a2a_profile: {supported_response_kinds:['message','task']}, agent_card: {url:'https://never-contact.example/',skills:[{name:'Risk',description:'Explain exposure',examples:['Explain risk']} ]}, configuration: {secret:'not-public'}, runtime_config: {token:'not-public'} }); function fixture() { - const calls = [], state = {account:'person@example.test',row:row(),catalog:['agent.list','agent.search','agent.get','a2a.send_message','a2a.get_task','a2a.wait_task','a2a.cancel_task']}; + const calls = [], state = {account:'person@example.test',row:row(),catalog:['agent.list','agent.search','agent.get','agent.get_or_create_session','a2a.send_message','a2a.get_task','a2a.wait_task','a2a.cancel_task']}; const task = () => ({agent_uid:agentUid,agent_session_uid:sessionUid,task_uid:taskUid,status:'working',terminal:false,event_cursor:2,outputs:[],cancellation_requested:false}); const session = {status:async (url,check)=>{assert.equal(url,backend);assert.equal(check,true);return {authenticated:true,username:state.account}},sanitize:s=>s.replaceAll('known-secret','[redacted]'), request:async(url,method,path,body,retrySafe,options)=>{ @@ -16,11 +16,12 @@ function fixture() { const reply = result=>({jsonrpc:'2.0',id:body.id,result}); if(body.method==='initialize')return reply({protocolVersion:'2025-11-25',capabilities:{tools:{}}}); if(body.method==='notifications/initialized')return; - if(body.method==='tools/list')return reply({tools:state.catalog.map(name=>({name,inputSchema:{type:'object'}}))}); + if(body.method==='tools/list')return reply({tools:state.catalog.map(name=>({name,inputSchema:{type:'object'},...(state.timeoutMeta===undefined?{}:{_meta:{'mainsequence.ai/timeout-seconds/v1':state.timeoutMeta}})}))}); const {name,arguments:args}=body.params;let data; if(name==='agent.list')data={count:35,results:[state.row]}; if(name==='agent.search')data={results:[state.row]}; if(name==='agent.get')data=state.row; + if(name==='agent.get_or_create_session')data={uid:args.session_uid||sessionUid,agent_uid:agentUid,organization_environment_uid:environmentUid,bound_handle:{handle_unique_id:args.handle_unique_id},runtime_token:'not-public',...(state.sessionOverride||{})}; if(name==='a2a.get_task')data={...task(),...(state.taskOverride||{})}; if(name==='a2a.wait_task')data={...task(),events:[],timed_out:true}; if(name==='a2a.cancel_task')data={...task(),cancellation_requested:true}; @@ -50,7 +51,7 @@ test('send allocates a stable handle; follow-up reuses its session with no repla const f=fixture();const first=await f.send();assert.equal(first.status,'success');assert.equal(first.data.sessionUid,sessionUid);assert.match(first.data.message,/\[redacted\]/); await f.send({handleId:undefined,sessionUid});const sends=f.calls.filter(c=>c.body.params?.name==='a2a.send_message');assert.equal(sends.length,2); assert.equal(sends[0].body.params.arguments.handle_unique_id,'claude_conversation_1');assert.equal(sends[1].body.params.arguments.agent_session_uid,sessionUid); - assert.ok(sends.every(c=>c.retrySafe===false&&c.options.timeoutMs===75000));assert.ok(sends.every(c=>!Object.hasOwn(c.body.params.arguments,'completion_policy'))); + assert.ok(sends.every(c=>c.retrySafe===false&&c.options.timeoutMs===145000));assert.ok(sends.every(c=>!Object.hasOwn(c.body.params.arguments,'completion_policy'))); assert.notEqual(sends[0].body.params.arguments.message_id,sends[1].body.params.arguments.message_id); }); test('changed account, missing account binding, malformed selections and undeployed agents never send',async()=>{ @@ -85,3 +86,31 @@ test('an ambiguous send or mismatched response is reported without repeating the test('an older platform without messaging reports unsupported capability before sending',async()=>{ const f=fixture();f.state.catalog=f.state.catalog.filter(n=>n!=='a2a.send_message');const result=await f.send();assert.equal(result.error.code,'UNSUPPORTED_ACTION');assert.equal(result.effects,undefined); }); + +test('session resolution creates or resumes without messaging, exposes only public identity, and is safely repeatable',async()=>{ + const f=fixture(), selection={agentUid,expectedAccount:'person@example.test',handleId:'claude_host_agent_default'}; + const first=await f.dispatch('agent-session',selection);assert.equal(first.status,'success');assert.equal(first.data.sessionUid,sessionUid); + await f.dispatch('agent-session',selection); + await f.dispatch('agent-session',{agentUid,expectedAccount:'person@example.test',sessionUid}); + const calls=f.calls.filter(c=>c.body.params?.name==='agent.get_or_create_session');assert.equal(calls.length,3); + assert.deepEqual(calls[0].body.params.arguments,{agent_uid:agentUid,handle_unique_id:selection.handleId}); + assert.deepEqual(calls[2].body.params.arguments,{agent_uid:agentUid,session_uid:sessionUid});assert.ok(calls.every(c=>c.retrySafe)); + assert.equal(f.calls.some(c=>c.body.params?.name==='a2a.send_message'),false); + assert.doesNotMatch(JSON.stringify(first),/runtime_token|not-public|parent_session/); +}); +test('session resolution rejects mismatched identities and missing account or ambiguous selectors',async()=>{ + for(const extra of [{},{expectedAccount:'person@example.test',handleId:'bad/handle'},{expectedAccount:'person@example.test',handleId:'ok',sessionUid}]){ + const f=fixture();assert.equal((await f.dispatch('agent-session',{agentUid,...extra})).status,'failure'); + assert.equal(f.calls.some(c=>c.body.params?.name==='agent.get_or_create_session'),false); + } + for(const override of [{uid:'invalid'},{agent_uid:id(99)},{organization_environment_uid:id(99)},{bound_handle:{handle_unique_id:'wrong'}}]){ + const f=fixture();f.state.sessionOverride=override; + assert.equal((await f.dispatch('agent-session',{agentUid,expectedAccount:'person@example.test',handleId:'ok'})).status,'failure'); + } +}); +test('advertised tool deadlines are bounded and invalid values cannot shorten agent sends',async()=>{ + for(const [value,expected] of [[140,145000],[200,205000],[295,300000],[-1,145000],[999999,145000],['140',145000],[0,145000]]){ + const f=fixture();f.state.timeoutMeta=value;assert.equal((await f.send()).status,'success'); + assert.equal(f.calls.find(c=>c.body.params?.name==='a2a.send_message').options.timeoutMs,expected); + } +}); diff --git a/tests/session.test.cjs b/tests/session.test.cjs index a013542..7b385e3 100644 --- a/tests/session.test.cjs +++ b/tests/session.test.cjs @@ -155,3 +155,16 @@ test('a refused renewal names the saved session, its expiry and the sign-in, and assert.match(result.error.message, /^The backend refused the saved session for http:\/\/127\.0\.0\.1:\d+: it expired on \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\. Sign in again\.$/); assert.equal(readSession(store, backend).refresh, expiredRefresh); }); + + +test('per-request timeout accommodates the advertised agent deadline and rejects values beyond the cap before HTTP', async t => { + const { backend, calls } = await fixtureBackend(t); + const store = memoryStore(); saveSession(store, backend, {username:'fixture',access:'fixture-access-1',refresh:'fixture-refresh-1'}); + const service = new MachineSessionService(createNodeHost(), {env:{},store}); + await service.request(backend, 'GET', '/api/v1/code-repositories/', undefined, true, {timeoutMs:145000}); + const count = calls.length; + for (const timeoutMs of [0,-1,NaN,Infinity,300001]) { + await assert.rejects(service.request(backend, 'GET', '/api/v1/code-repositories/', undefined, true, {timeoutMs}), error => error.code === 'INVALID_REQUEST'); + } + assert.equal(calls.length, count); +});