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
32 changes: 24 additions & 8 deletions docs/agent-conversations.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 |
Expand All @@ -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;
Expand All @@ -42,17 +50,19 @@ 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.

## 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.

Expand All @@ -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.
2 changes: 1 addition & 1 deletion manifests/codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
40 changes: 36 additions & 4 deletions src/shared/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<string, number>();
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<string, unknown>, mutation = false): Promise<any> => {
if (!catalog.has(name)) throw new ToolingError('UNSUPPORTED_ACTION', `The connected platform does not offer ${name}.`);
this.options.signal?.throwIfAborted();
Expand Down Expand Up @@ -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<string, unknown>;
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.');
Expand Down
2 changes: 1 addition & 1 deletion src/shared/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export class MachineSessionService implements SessionService {
};
}
async request<T>(input: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe = method === 'GET', options?: { timeoutMs?: number }): Promise<T> {
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)) {
Expand Down
35 changes: 32 additions & 3 deletions tests/agents.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ 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)=>{
calls.push({url,method,path,body,retrySafe,options});assert.equal(path,'/mcp');assert.equal(url,backend);
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};
Expand Down Expand Up @@ -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()=>{
Expand Down Expand Up @@ -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);
}
});
Loading
Loading