beeos-claw 0.5.26 → 0.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a2a/tools.js +1 -1
- package/dist/a2a/tools.test.js +1 -1
- package/dist/acp-gateway/bridge-core-agent-create.test.js +1 -1
- package/dist/acp-gateway/bridge-core-apply-template.test.js +1 -1
- package/dist/acp-gateway/bridge-core.js +1 -1
- package/dist/acp-gateway/file-artifact-presenter.js +1 -0
- package/dist/acp-gateway/file-artifact-presenter.test.js +1 -0
- package/dist/acp-gateway/gateway-events-file-artifact.test.js +1 -0
- package/dist/acp-gateway/gateway-events.js +1 -1
- package/dist/acp-gateway/session-state.js +1 -1
- package/dist/acp-gateway/tool-result-payload-strategies.js +1 -1
- package/dist/acp-gateway/tool-result-payload-strategies.test.js +1 -0
- package/dist/acp-server.js +1 -1
- package/dist/canvas/tools.js +1 -1
- package/dist/files/beeos-file-uri.js +1 -1
- package/dist/files/beeos-file-uri.test.js +1 -1
- package/package.json +1 -1
package/dist/a2a/tools.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{agentFetch}from"../agent-auth.js";import{resolveAgentIdFromMentions}from"../delegation/mention-resolver.js";async function a2aFetch(t,e,a){const s=`${t.agentGatewayUrl}${e}`;return t.agentKeys?agentFetch(s,t.agentKeys,a):fetch(s,a)}export{a2aFetch};function normalizeState(t){if(!t)return"";const e=t.toLowerCase();return{task_state_submitted:"submitted",task_state_delivered:"submitted",task_state_working:"working",task_state_completed:"completed",task_state_failed:"failed",task_state_timeout:"timeout",task_state_canceled:"canceled",task_state_rejected:"rejected",task_state_input_required:"input-required",task_state_auth_required:"auth-required"}[e]??e}function unwrapTask(t){if(!t||"object"!=typeof t)return;const e=t,a=e.task;return a&&"object"==typeof a?a:e}function extractArtifactText(t){const e=t?.artifacts;if(!e)return;return e.flatMap(t=>t.parts??[]).filter(t=>"string"==typeof t.text&&(void 0===t.kind||"text"===t.kind)).map(t=>t.text).join("\n")||void 0}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function stringValue(t){return"string"==typeof t&&""!==t?t:void 0}function normalizeArtifactFilePart(t){const e=stringValue(t.kind),a=isRecord(t.file)?t.file:void 0;let s=stringValue(t.url),r=stringValue(t.filename),n=stringValue(t.mediaType);if(a&&(s=stringValue(a.uri)??stringValue(a.url)??s,r=stringValue(a.name)??stringValue(a.filename)??r,n=stringValue(a.mimeType)??stringValue(a.mediaType)??n),s&&(!e||"file"===e))return{uri:s,...r?{name:r}:{},...n?{mimeType:n}:{}}}function extractArtifactFiles(t){const e=t?.artifacts;if(!e)return;const a=[];for(const t of e){const e=t.parts;if(Array.isArray(e))for(const t of e){if(!isRecord(t))continue;const e=normalizeArtifactFilePart(t);e&&a.push(e)}}return a.length>0?a:void 0}function extractStatusText(t){const e=t?.status,a=e?.message?.parts,s=a?.[0]?.text;return"string"==typeof s?s:void 0}function taskState(t){return normalizeState(t?.status?.state)}const TERMINAL_FAILURE_STATES=new Set(["failed","timeout","canceled","rejected"]);function taskContextId(t){const e=t?.contextId;return"string"==typeof e&&""!==e?e:void 0}function buildTaskPayload(t,e,a){const s=taskState(t),r=extractArtifactText(t),n=extractArtifactFiles(t),i=extractStatusText(t),o=taskContextId(t),c={status:s,is_terminal:"completed"===s||TERMINAL_FAILURE_STATES.has(s)};return e&&(c.task_id=e),a&&(c.agent_id=a),o&&(c.context_id=o),r?c.result=r:i&&(c.error=i),n&&(c.files=n),c}export{unwrapTask,extractArtifactText,extractArtifactFiles,extractStatusText,taskState,taskContextId,normalizeState,buildTaskPayload};export class TaskHandleRegistry{seq=0;handles=new Map;taskIdToHandle=new Map;create(t,e,a,s){const r="bth_"+ ++this.seq;return this.handles.set(r,{taskId:t,agentId:e,contextId:a,sessionKey:s}),this.taskIdToHandle.set(t,r),r}resolveTaskId(t){const e=this.handles.get(t);return e?e.taskId:t}resolveAgentId(t){const e=this.handles.get(t);if(e)return e.agentId;const a=this.taskIdToHandle.get(t);return a?this.handles.get(a)?.agentId:void 0}}const defaultRegistry=new TaskHandleRegistry;export function registerA2ATools(t,e,a=defaultRegistry,s,r,n,i){t.registerTool({name:"beeos_discover_agents",description:"List agents available for A2A collaboration. When called without a query, returns all visible agents (your own agents + public agents). When called with a query, filters by name, description, or skill. Results include an `isSelf` flag marking the agent that represents the current instance (do not call yourself). Each result includes `instanceName` (human-readable device/instance label) when available.",parameters:{type:"object",properties:{query:{type:"string",description:"Optional keyword to filter agents by name, description, or skill. Omit to list all agents."}},required:[]},async execute(t,a,s){const{query:r}=a??{};try{const t=new URLSearchParams({limit:"50"});r&&t.set("query",r);const a=await a2aFetch(e,`/api/v1/a2a/discover?${t}`,{signal:s});if(!a.ok)return{content:JSON.stringify({error:`discover failed: ${a.status}`})};const n=await a.json(),o=i?.();if(o&&Array.isArray(n?.data))for(const t of n.data)t.isSelf=t.instance_id===o;return{content:JSON.stringify(n,null,2)}}catch(t){if(s?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t)})}}}}),t.registerTool({name:"beeos_call_agent",description:"Ask another agent to perform a task. This creates the task ASYNCHRONOUSLY and returns immediately with a handle ({ task_id, agent_id, context_id, status }) — it does NOT wait for the result. To fetch the agent's reply, call beeos_get_task with the returned task_id (and agent_id). Do NOT call beeos_call_agent again to poll an existing task — that creates a brand-new task.",parameters:{type:"object",properties:{agentId:{type:"string",description:"The target agent UUID (e.g. d04080d1-...) or display name. Prefer the UUID from discover results or @mention metadata when available."},message:{type:"string",description:"The task description or message to send to the agent"},taskId:{type:"string",description:"Optional existing task ID to continue a multi-turn conversation. When provided, the message is appended to the existing task instead of creating a new one."},files:{type:"array",items:{type:"string"},description:"Optional file URLs to include with the request"},contextId:{type:"string",description:"Optional context ID for multi-turn conversations with the same agent"},timeoutMs:{type:"number",description:"Server-side task timeout in milliseconds (how long the target agent may run before the task is timed out). Defaults to 600000 (10 minutes). This is NOT a client wait time — beeos_call_agent returns immediately regardless."}},required:["agentId","message"]},async execute(t,i,o){let{agentId:c,message:d,files:l,contextId:u,timeoutMs:g,taskId:f}=i;const h=s?.(t),p=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;if(!p.test(c)){const a=r?.(t);if(a?.length){const t=resolveAgentIdFromMentions(a,c);t&&(e.logger.info?.(`beeos_call_agent: resolved "${c}" → ${t} via mention context`),c=t)}if(!p.test(c))try{const t=await a2aFetch(e,`/api/v1/a2a/discover?query=${encodeURIComponent(c)}&limit=10`,{signal:o});if(t.ok){const a=await t.json(),s=a?.data??[],r=c.toLowerCase(),n=s.find(t=>t.name.toLowerCase()===r)??s.find(t=>t.name.toLowerCase().includes(r));if(!n)return{content:JSON.stringify({error:`Could not resolve agent name "${c}". Available agents: ${s.map(t=>`${t.name} (${t.id})`).join(", ")||"none"}`})};e.logger.info?.(`beeos_call_agent: resolved "${c}" → ${n.id} (${n.name}) via discover`),c=n.id}}catch{}}const m=u||(h?`session:${h}`:""),y=g||6e5;try{const t={jsonrpc:"2.0",id:`claw-${Date.now()}`,method:"SendMessage",params:{message:{role:"user",parts:[{text:d}],...m?{contextId:m}:{},...f?{taskId:f}:{}},configuration:{timeoutMs:y}}},s=`/api/v1/a2a/${encodeURIComponent(c)}`,r=AbortSignal.timeout(3e4),i=o?AbortSignal.any([o,r]):r,l=await a2aFetch(e,s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),signal:i}),g=await l.text();let p;try{p=JSON.parse(g)}catch{p={}}if(!l.ok||p.error){if(401===l.status||403===l.status){const t=p.error??{};-32600===t.code&&n&&n("_beeos/auth_required",{type:"auth_required",target_agent_id:c,target_agent_name:c,title:"Authentication Required",content:`Agent **${c}** requires authentication.`})}const t=p.error?JSON.stringify(p.error):g;return{content:JSON.stringify({error:`send task failed: ${l.status} ${t}`,agent_id:c})}}const k=unwrapTask(p.result),_=k?.id;return _?(a.create(_,c,u,h),{content:JSON.stringify({status:taskState(k)||"working",task_id:_,agent_id:c,context_id:taskContextId(k)??m??void 0,message:`Task created. Call beeos_get_task with task_id "${_}" (and agent_id "${c}") to fetch status and result.`})}):{content:JSON.stringify({error:"task created but server returned no task_id",agent_id:c})}}catch(t){if(o?.aborted)throw t;return t instanceof DOMException&&"TimeoutError"===t.name?{content:JSON.stringify({error:"create task request timed out after 30000ms",agent_id:c})}:{content:JSON.stringify({error:t instanceof Error?t.message:String(t),agent_id:c})}}}}),t.registerTool({name:"beeos_get_task",description:'Fetch the current state AND data of an existing A2A task by its task_id. Use this to check on a task that beeos_call_agent created (it returns status "working" with a task_id), or to re-read a finished task. Always returns a JSON object { status, task_id, agent_id, context_id, is_terminal, result?, files?, error? }: `result` holds the produced output (present whenever the device produced output, EVEN for timeout/canceled/failed tasks that still produced content); `files` contains structured file artifacts as BeeOS platform resource URIs ({ uri, name, mimeType }). For image display, reference files[].uri directly in chat or Canvas; do not upload/fetch/browser/shell files[].uri for display. Only use beeos_fetch_file when local byte analysis is required. `error` holds the failure reason when there is no produced output; `is_terminal` indicates whether the task has reached a final state. Do NOT call beeos_call_agent again to re-query an existing task — that creates a brand-new task.',parameters:{type:"object",properties:{taskId:{type:"string",description:"The task ID returned by a previous beeos_call_agent call."},agentId:{type:"string",description:"The target agent UUID that owns the task (the same agentId passed to beeos_call_agent). Optional when the task was created earlier in this session — it is resolved automatically."}},required:["taskId"]},async execute(t,s,r){const{taskId:n,agentId:i}=s??{};if(!n)return{content:JSON.stringify({error:"taskId is required"})};const o=i||a.resolveAgentId(n);if(!o)return{content:JSON.stringify({error:`agentId is required to query task "${n}" (it was not created in this session, so the target agent is unknown). Pass the agentId you originally called.`,task_id:n})};try{const t=`/api/v1/a2a/${encodeURIComponent(o)}`,a=await a2aFetch(e,t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:`claw-get-${Date.now()}`,method:"GetTask",params:{id:n}}),signal:r}),s=await a.text();let i;try{i=JSON.parse(s)}catch{i={}}if(!a.ok||i.error){const t=i.error?JSON.stringify(i.error):s;return{content:JSON.stringify({error:`get task failed: ${a.status} ${t}`,task_id:n,agent_id:o})}}const c=unwrapTask(i.result);return{content:JSON.stringify(buildTaskPayload(c,n,o))}}catch(t){if(r?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t),task_id:n,agent_id:o})}}}})}
|
|
1
|
+
import{agentFetch}from"../agent-auth.js";import{resolveAgentIdFromMentions}from"../delegation/mention-resolver.js";async function a2aFetch(t,e,a){const n=`${t.agentGatewayUrl}${e}`;return t.agentKeys?agentFetch(n,t.agentKeys,a):fetch(n,a)}export{a2aFetch};function normalizeState(t){if(!t)return"";const e=t.toLowerCase();return{task_state_submitted:"submitted",task_state_delivered:"submitted",task_state_working:"working",task_state_completed:"completed",task_state_failed:"failed",task_state_timeout:"timeout",task_state_canceled:"canceled",task_state_rejected:"rejected",task_state_input_required:"input-required",task_state_auth_required:"auth-required"}[e]??e}function unwrapTask(t){if(!t||"object"!=typeof t)return;const e=t,a=e.task;return a&&"object"==typeof a?a:e}function extractArtifactText(t){const e=t?.artifacts;if(!e)return;return e.flatMap(t=>t.parts??[]).filter(t=>"string"==typeof t.text&&(void 0===t.kind||"text"===t.kind)).map(t=>t.text).join("\n")||void 0}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function stringValue(t){return"string"==typeof t&&""!==t?t:void 0}function normalizeArtifactFilePart(t){const e=stringValue(t.kind),a=isRecord(t.file)?t.file:void 0;let n=stringValue(t.url),s=stringValue(t.filename),r=stringValue(t.mediaType);if(a&&(n=stringValue(a.uri)??stringValue(a.url)??n,s=stringValue(a.name)??stringValue(a.filename)??s,r=stringValue(a.mimeType)??stringValue(a.mediaType)??r),n&&(!e||"file"===e))return{uri:n,...s?{name:s}:{},...r?{mimeType:r}:{}}}function extractArtifactFiles(t){const e=t?.artifacts;if(!e)return;const a=[];for(const t of e){const e=t.parts;if(Array.isArray(e))for(const t of e){if(!isRecord(t))continue;const e=normalizeArtifactFilePart(t);e&&a.push(e)}}return a.length>0?a:void 0}function extractStatusText(t){const e=t?.status,a=e?.message?.parts,n=a?.[0]?.text;return"string"==typeof n?n:void 0}function taskState(t){return normalizeState(t?.status?.state)}const TERMINAL_FAILURE_STATES=new Set(["failed","timeout","canceled","rejected"]);function taskContextId(t){const e=t?.contextId;return"string"==typeof e&&""!==e?e:void 0}function buildTaskPayload(t,e,a){const n=taskState(t),s=extractArtifactText(t),r=extractArtifactFiles(t),i=extractStatusText(t),o=taskContextId(t),c={status:n,is_terminal:"completed"===n||TERMINAL_FAILURE_STATES.has(n)};return e&&(c.task_id=e),a&&(c.agent_id=a),o&&(c.context_id=o),s?c.result=s:i&&(c.error=i),r&&(c.files=r),c}export{unwrapTask,extractArtifactText,extractArtifactFiles,extractStatusText,taskState,taskContextId,normalizeState,buildTaskPayload};export class TaskHandleRegistry{seq=0;handles=new Map;taskIdToHandle=new Map;create(t,e,a,n){const s="bth_"+ ++this.seq;return this.handles.set(s,{taskId:t,agentId:e,contextId:a,sessionKey:n}),this.taskIdToHandle.set(t,s),s}resolveTaskId(t){const e=this.handles.get(t);return e?e.taskId:t}resolveAgentId(t){const e=this.handles.get(t);if(e)return e.agentId;const a=this.taskIdToHandle.get(t);return a?this.handles.get(a)?.agentId:void 0}}const defaultRegistry=new TaskHandleRegistry;export function registerA2ATools(t,e,a=defaultRegistry,n,s,r,i){t.registerTool({name:"beeos_discover_agents",description:"List agents available for A2A collaboration. When called without a query, returns all visible agents (your own agents + public agents). When called with a query, filters by name, description, or skill. Results include an `isSelf` flag marking the agent that represents the current instance (do not call yourself). Each result includes `instanceName` (human-readable device/instance label) when available.",parameters:{type:"object",properties:{query:{type:"string",description:"Optional keyword to filter agents by name, description, or skill. Omit to list all agents."}},required:[]},async execute(t,a,n){const{query:s}=a??{};try{const t=new URLSearchParams({limit:"50"});s&&t.set("query",s);const a=await a2aFetch(e,`/api/v1/a2a/discover?${t}`,{signal:n});if(!a.ok)return{content:JSON.stringify({error:`discover failed: ${a.status}`})};const r=await a.json(),o=i?.();if(o&&Array.isArray(r?.data))for(const t of r.data)t.isSelf=t.instance_id===o;return{content:JSON.stringify(r,null,2)}}catch(t){if(n?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t)})}}}}),t.registerTool({name:"beeos_call_agent",description:"Ask another agent to perform a task. This creates the task ASYNCHRONOUSLY and returns immediately with a handle ({ task_id, agent_id, context_id, status }) — it does NOT wait for the result. To fetch the agent's reply, call beeos_get_task with the returned task_id (and agent_id). Do NOT call beeos_call_agent again to poll an existing task — that creates a brand-new task.",parameters:{type:"object",properties:{agentId:{type:"string",description:"The target agent UUID (e.g. d04080d1-...) or display name. Prefer the UUID from discover results or @mention metadata when available."},message:{type:"string",description:"The task description or message to send to the agent"},taskId:{type:"string",description:"Optional existing task ID to continue a multi-turn conversation. When provided, the message is appended to the existing task instead of creating a new one."},files:{type:"array",items:{type:"string"},description:"Optional file URLs to include with the request"},contextId:{type:"string",description:"Optional context ID for multi-turn conversations with the same agent"},timeoutMs:{type:"number",description:"Server-side task timeout in milliseconds (how long the target agent may run before the task is timed out). Defaults to 600000 (10 minutes). This is NOT a client wait time — beeos_call_agent returns immediately regardless."}},required:["agentId","message"]},async execute(t,i,o){let{agentId:c,message:d,files:l,contextId:u,timeoutMs:g,taskId:f}=i;const h=n?.(t),p=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;if(!p.test(c)){const a=s?.(t);if(a?.length){const t=resolveAgentIdFromMentions(a,c);t&&(e.logger.info?.(`beeos_call_agent: resolved "${c}" → ${t} via mention context`),c=t)}if(!p.test(c))try{const t=await a2aFetch(e,`/api/v1/a2a/discover?query=${encodeURIComponent(c)}&limit=10`,{signal:o});if(t.ok){const a=await t.json(),n=a?.data??[],s=c.toLowerCase(),r=n.find(t=>t.name.toLowerCase()===s)??n.find(t=>t.name.toLowerCase().includes(s));if(!r)return{content:JSON.stringify({error:`Could not resolve agent name "${c}". Available agents: ${n.map(t=>`${t.name} (${t.id})`).join(", ")||"none"}`})};e.logger.info?.(`beeos_call_agent: resolved "${c}" → ${r.id} (${r.name}) via discover`),c=r.id}}catch{}}const m=u||(h?`session:${h}`:""),y=g||6e5;try{const t={jsonrpc:"2.0",id:`claw-${Date.now()}`,method:"SendMessage",params:{message:{role:"user",parts:[{text:d}],...m?{contextId:m}:{},...f?{taskId:f}:{}},configuration:{timeoutMs:y}}},n=`/api/v1/a2a/${encodeURIComponent(c)}`,s=AbortSignal.timeout(3e4),i=o?AbortSignal.any([o,s]):s,l=await a2aFetch(e,n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),signal:i}),g=await l.text();let p;try{p=JSON.parse(g)}catch{p={}}if(!l.ok||p.error){if(401===l.status||403===l.status){const t=p.error??{};-32600===t.code&&r&&r("_beeos/auth_required",{type:"auth_required",target_agent_id:c,target_agent_name:c,title:"Authentication Required",content:`Agent **${c}** requires authentication.`})}const t=p.error?JSON.stringify(p.error):g;return{content:JSON.stringify({error:`send task failed: ${l.status} ${t}`,agent_id:c})}}const k=unwrapTask(p.result),_=k?.id;return _?(a.create(_,c,u,h),{content:JSON.stringify({status:taskState(k)||"working",task_id:_,agent_id:c,context_id:taskContextId(k)??m??void 0,message:`Task created. Call beeos_get_task with task_id "${_}" (and agent_id "${c}") to fetch status and result.`})}):{content:JSON.stringify({error:"task created but server returned no task_id",agent_id:c})}}catch(t){if(o?.aborted)throw t;return t instanceof DOMException&&"TimeoutError"===t.name?{content:JSON.stringify({error:"create task request timed out after 30000ms",agent_id:c})}:{content:JSON.stringify({error:t instanceof Error?t.message:String(t),agent_id:c})}}}}),t.registerTool({name:"beeos_get_task",description:'Fetch the current state AND data of an existing A2A task by its task_id. Use this to check on a task that beeos_call_agent created (it returns status "working" with a task_id), or to re-read a finished task. Always returns a JSON object { status, task_id, agent_id, context_id, is_terminal, result?, files?, error? }: `result` holds the produced output (present whenever the device produced output, EVEN for timeout/canceled/failed tasks that still produced content); `files` contains structured file artifacts ({uri,name,mimeType}). The runtime displays image files in Canvas when Canvas is active, otherwise as chat attachments. Do not output MEDIA:, and do not upload/fetch/browser/shell files[].uri for display. Use beeos_fetch_file only for local byte analysis. `error` holds the failure reason when there is no produced output; `is_terminal` indicates whether the task has reached a final state. Do NOT call beeos_call_agent again to re-query an existing task — that creates a brand-new task.',parameters:{type:"object",properties:{taskId:{type:"string",description:"The task ID returned by a previous beeos_call_agent call."},agentId:{type:"string",description:"The target agent UUID that owns the task (the same agentId passed to beeos_call_agent). Optional when the task was created earlier in this session — it is resolved automatically."}},required:["taskId"]},async execute(t,n,s){const{taskId:r,agentId:i}=n??{};if(!r)return{content:JSON.stringify({error:"taskId is required"})};const o=i||a.resolveAgentId(r);if(!o)return{content:JSON.stringify({error:`agentId is required to query task "${r}" (it was not created in this session, so the target agent is unknown). Pass the agentId you originally called.`,task_id:r})};try{const t=`/api/v1/a2a/${encodeURIComponent(o)}`,a=await a2aFetch(e,t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:`claw-get-${Date.now()}`,method:"GetTask",params:{id:r}}),signal:s}),n=await a.text();let i;try{i=JSON.parse(n)}catch{i={}}if(!a.ok||i.error){const t=i.error?JSON.stringify(i.error):n;return{content:JSON.stringify({error:`get task failed: ${a.status} ${t}`,task_id:r,agent_id:o})}}const c=unwrapTask(i.result);return{content:JSON.stringify(buildTaskPayload(c,r,o))}}catch(t){if(s?.aborted)throw t;return{content:JSON.stringify({error:t instanceof Error?t.message:String(t),task_id:r,agent_id:o})}}}})}
|
package/dist/a2a/tools.test.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,vi,afterEach}from"vitest";import{registerA2ATools,TaskHandleRegistry,unwrapTask,extractArtifactText,extractArtifactFiles,extractStatusText,taskState,buildTaskPayload}from"./tools.js";const AGENT_ID="d04080d1-1111-2222-3333-444455556666";function makeCfg(){return{agentGatewayUrl:"https://gw.test",agentKeys:null,logger:{info:vi.fn(),warn:vi.fn(),error:vi.fn(),debug:vi.fn()}}}function registerTools(t,e=new TaskHandleRegistry){const a=new Map;return registerA2ATools({registerTool:t=>a.set(t.name,t)},t,e),a}function jsonResponse(t,e=!0,a=200){const s=JSON.stringify(t);return{ok:e,status:a,text:async()=>s,json:async()=>JSON.parse(s)}}describe("unwrapTask",()=>{it("unwraps the v1.0 SendMessage { task } envelope",()=>{const t=unwrapTask({task:{id:"t1",status:{state:"TASK_STATE_WORKING"}}});expect(t?.id).toBe("t1")}),it("returns the bare task for v0.3 / GetTask results",()=>{const t=unwrapTask({id:"t2",status:{state:"working"}});expect(t?.id).toBe("t2")}),it("returns undefined for non-object input",()=>{expect(unwrapTask(void 0)).toBeUndefined(),expect(unwrapTask("nope")).toBeUndefined()})}),describe("extractArtifactText",()=>{it("reads v1.0 text parts ({ text } without kind)",()=>{expect(extractArtifactText({artifacts:[{parts:[{text:"hello"},{text:"world"}]}]})).toBe("hello\nworld")}),it("reads v0.3 text parts ({ kind: 'text', text })",()=>{expect(extractArtifactText({artifacts:[{parts:[{kind:"text",text:"hi"}]}]})).toBe("hi")}),it("skips non-text parts (e.g. file/data)",()=>{expect(extractArtifactText({artifacts:[{parts:[{kind:"file",url:"u"},{text:"kept"}]}]})).toBe("kept")}),it("returns undefined when there are no artifacts",()=>{expect(extractArtifactText({status:{state:"working"}})).toBeUndefined(),expect(extractArtifactText(void 0)).toBeUndefined()})}),describe("extractArtifactFiles",()=>{it("reads v1.0 flat file parts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{url:"beeos-file://file-1",filename:"screenshot.jpeg",mediaType:"image/jpeg"}]}]})).toEqual([{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}])}),it("reads v0.3 nested file parts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{kind:"file",file:{uri:"beeos-file://file-2",name:"capture.png",mimeType:"image/png"}}]}]})).toEqual([{uri:"beeos-file://file-2",name:"capture.png",mimeType:"image/png"}])}),it("does not infer files from text-only beeos-file URI artifacts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{text:"beeos-file://file-3"}]}]})).toBeUndefined()})}),describe("extractStatusText",()=>{it("reads the agent status message text",()=>{expect(extractStatusText({status:{message:{parts:[{text:"boom"}]}}})).toBe("boom")}),it("returns undefined when no status message",()=>{expect(extractStatusText({status:{}})).toBeUndefined()})}),describe("taskState",()=>{it("normalizes v1.0 SCREAMING_SNAKE states",()=>{expect(taskState({status:{state:"TASK_STATE_COMPLETED"}})).toBe("completed"),expect(taskState({status:{state:"TASK_STATE_WORKING"}})).toBe("working")}),it("passes through v0.3 lowercase states",()=>{expect(taskState({status:{state:"failed"}})).toBe("failed")})}),describe("buildTaskPayload",()=>{it("surfaces produced content with the terminal state (canceled with artifact)",()=>{expect(buildTaskPayload({contextId:"ctx-1",status:{state:"TASK_STATE_CANCELED"},artifacts:[{parts:[{text:"partial work"}]}]},"t1","agent-1")).toEqual({status:"canceled",task_id:"t1",agent_id:"agent-1",context_id:"ctx-1",is_terminal:!0,result:"partial work"})}),it("reports status-message text as error when there is no artifact",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_FAILED",message:{parts:[{text:"boom"}]}}},"t2","agent-2")).toEqual({status:"failed",task_id:"t2",agent_id:"agent-2",is_terminal:!0,error:"boom"})}),it("marks a working task as non-terminal with neither result nor error",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_WORKING"}},"t3","agent-3")).toEqual({status:"working",task_id:"t3",agent_id:"agent-3",is_terminal:!1})}),it("includes result for a completed task",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"the answer"}]}]},"t4","agent-4")).toEqual({status:"completed",task_id:"t4",agent_id:"agent-4",is_terminal:!0,result:"the answer"})}),it("returns result text and structured files together",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"beeos-file://file-1"},{url:"beeos-file://file-1",filename:"screenshot.jpeg",mediaType:"image/jpeg"}]}]},"t5","agent-5")).toEqual({status:"completed",task_id:"t5",agent_id:"agent-5",is_terminal:!0,result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}),it("does not turn text-only beeos-file URI results into files",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"beeos-file://file-only-text"}]}]},"t6","agent-6")).toEqual({status:"completed",task_id:"t6",agent_id:"agent-6",is_terminal:!0,result:"beeos-file://file-only-text"})})}),describe("beeos_call_agent async create",()=>{afterEach(()=>{vi.unstubAllGlobals()}),it("creates the task and returns the handle (task_id, agent_id, context_id) without polling or task data",async()=>{const t=[],e=vi.fn(async(e,a)=>{const s=a?.body?JSON.parse(a.body):{};if(t.push({url:e,method:a?.method,rpcMethod:s.method}),"SendMessage"===s.method)return jsonResponse({jsonrpc:"2.0",id:s.id,result:{task:{id:"task-async-1",contextId:"c1",status:{state:"TASK_STATE_WORKING"}}}});throw new Error(`unexpected request method: ${s.method}`)});vi.stubGlobal("fetch",e);const a=registerTools(makeCfg()).get("beeos_call_agent"),s=await a.execute("tc-1",{agentId:AGENT_ID,message:"do it"}),r=JSON.parse(s.content);expect(r.status).toBe("working"),expect(r.task_id).toBe("task-async-1"),expect(r.agent_id).toBe(AGENT_ID),expect(r.context_id).toBe("c1"),expect(r.result).toBeUndefined(),expect(t).toHaveLength(1),expect(t[0].rpcMethod).toBe("SendMessage"),expect(t[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`)}),it("registers the task so beeos_get_task can resolve the agent id",async()=>{const t=vi.fn(async(t,e)=>{const a=e?.body?JSON.parse(e.body):{};return"SendMessage"===a.method?jsonResponse({jsonrpc:"2.0",id:a.id,result:{task:{id:"task-reg-1",status:{state:"TASK_STATE_WORKING"}}}}):jsonResponse({jsonrpc:"2.0",id:a.id,result:{id:"task-reg-1",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"done"}]}]}})});vi.stubGlobal("fetch",t);const e=new TaskHandleRegistry,a=registerTools(makeCfg(),e);await a.get("beeos_call_agent").execute("tc",{agentId:AGENT_ID,message:"do it"});const s=await a.get("beeos_get_task").execute("tc",{taskId:"task-reg-1"}),r=JSON.parse(s.content);expect(r.status).toBe("completed"),expect(r.result).toBe("done"),expect(r.agent_id).toBe(AGENT_ID)}),it("returns an error handle (with agent_id, no task_id) when create fails",async()=>{const t=vi.fn(async(t,e)=>jsonResponse({jsonrpc:"2.0",id:(e?.body?JSON.parse(e.body):{}).id,error:{code:-32e3,message:"boom"}},!1,500));vi.stubGlobal("fetch",t);const e=registerTools(makeCfg()).get("beeos_call_agent"),a=await e.execute("tc",{agentId:AGENT_ID,message:"do it"}),s=JSON.parse(a.content);expect(s.error).toContain("send task failed"),expect(s.agent_id).toBe(AGENT_ID),expect(s.task_id).toBeUndefined()})}),describe("beeos_get_task",()=>{function t(t){const e=[],a=vi.fn(async(a,s)=>{const r=s?.body?JSON.parse(s.body):{};if(e.push({url:a,method:s?.method,rpcMethod:r.method,params:r.params}),"GetTask"!==r.method)throw new Error(`unexpected method: ${r.method}`);return jsonResponse({jsonrpc:"2.0",id:r.id,result:t})});return vi.stubGlobal("fetch",a),e}afterEach(()=>{vi.unstubAllGlobals()}),it("returns the data of a completed task and queries the right endpoint",async()=>{const e=t({id:"task-1",contextId:"ctx-1",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"the answer"}]}]}),a=registerTools(makeCfg()).get("beeos_get_task"),s=await a.execute("tc",{taskId:"task-1",agentId:AGENT_ID}),r=JSON.parse(s.content);expect(r.status).toBe("completed"),expect(r.task_id).toBe("task-1"),expect(r.agent_id).toBe(AGENT_ID),expect(r.context_id).toBe("ctx-1"),expect(r.is_terminal).toBe(!0),expect(r.result).toBe("the answer"),expect(e).toHaveLength(1),expect(e[0].method).toBe("POST"),expect(e[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`),expect(e[0].params.id).toBe("task-1")}),it("surfaces partial output AND the terminal state for a canceled task that still produced an artifact",async()=>{t({id:"task-cx",status:{state:"TASK_STATE_CANCELED"},artifacts:[{parts:[{text:"partial plan"}]}]});const e=registerTools(makeCfg()).get("beeos_get_task"),a=await e.execute("tc",{taskId:"task-cx",agentId:AGENT_ID}),s=JSON.parse(a.content);expect(s.status).toBe("canceled"),expect(s.task_id).toBe("task-cx"),expect(s.result).toBe("partial plan")}),it("returns an error for a terminal task with no produced content",async()=>{t({id:"task-to",status:{state:"TASK_STATE_FAILED",message:{parts:[{text:"boom"}]}}});const e=registerTools(makeCfg()).get("beeos_get_task"),a=await e.execute("tc",{taskId:"task-to",agentId:AGENT_ID}),s=JSON.parse(a.content);expect(s.error).toBe("boom"),expect(s.task_id).toBe("task-to")}),it("reports a still-running task as working",async()=>{t({id:"task-w",status:{state:"TASK_STATE_WORKING"}});const e=registerTools(makeCfg()).get("beeos_get_task"),a=await e.execute("tc",{taskId:"task-w",agentId:AGENT_ID}),s=JSON.parse(a.content);expect(s.status).toBe("working"),expect(s.task_id).toBe("task-w")}),it("resolves agentId from the session registry when omitted",async()=>{const e=new TaskHandleRegistry;e.create("task-reg",AGENT_ID);const a=t({id:"task-reg",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"ok"}]}]}),s=registerTools(makeCfg(),e).get("beeos_get_task"),r=await s.execute("tc",{taskId:"task-reg"}),o=JSON.parse(r.content);expect(o.status).toBe("completed"),expect(o.result).toBe("ok"),expect(o.agent_id).toBe(AGENT_ID),expect(a[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`)}),it("errors when agentId is neither passed nor resolvable",async()=>{const t=vi.fn();vi.stubGlobal("fetch",t);const e=registerTools(makeCfg()).get("beeos_get_task"),a=await e.execute("tc",{taskId:"unknown-task"}),s=JSON.parse(a.content);expect(s.error).toContain("agentId is required"),expect(t).not.toHaveBeenCalled()})});
|
|
1
|
+
import{describe,it,expect,vi,afterEach}from"vitest";import{registerA2ATools,TaskHandleRegistry,unwrapTask,extractArtifactText,extractArtifactFiles,extractStatusText,taskState,buildTaskPayload}from"./tools.js";const AGENT_ID="d04080d1-1111-2222-3333-444455556666";function makeCfg(){return{agentGatewayUrl:"https://gw.test",agentKeys:null,logger:{info:vi.fn(),warn:vi.fn(),error:vi.fn(),debug:vi.fn()}}}function registerTools(t,e=new TaskHandleRegistry){const s=new Map;return registerA2ATools({registerTool:t=>s.set(t.name,t)},t,e),s}function jsonResponse(t,e=!0,s=200){const a=JSON.stringify(t);return{ok:e,status:s,text:async()=>a,json:async()=>JSON.parse(a)}}describe("unwrapTask",()=>{it("unwraps the v1.0 SendMessage { task } envelope",()=>{const t=unwrapTask({task:{id:"t1",status:{state:"TASK_STATE_WORKING"}}});expect(t?.id).toBe("t1")}),it("returns the bare task for v0.3 / GetTask results",()=>{const t=unwrapTask({id:"t2",status:{state:"working"}});expect(t?.id).toBe("t2")}),it("returns undefined for non-object input",()=>{expect(unwrapTask(void 0)).toBeUndefined(),expect(unwrapTask("nope")).toBeUndefined()})}),describe("extractArtifactText",()=>{it("reads v1.0 text parts ({ text } without kind)",()=>{expect(extractArtifactText({artifacts:[{parts:[{text:"hello"},{text:"world"}]}]})).toBe("hello\nworld")}),it("reads v0.3 text parts ({ kind: 'text', text })",()=>{expect(extractArtifactText({artifacts:[{parts:[{kind:"text",text:"hi"}]}]})).toBe("hi")}),it("skips non-text parts (e.g. file/data)",()=>{expect(extractArtifactText({artifacts:[{parts:[{kind:"file",url:"u"},{text:"kept"}]}]})).toBe("kept")}),it("returns undefined when there are no artifacts",()=>{expect(extractArtifactText({status:{state:"working"}})).toBeUndefined(),expect(extractArtifactText(void 0)).toBeUndefined()})}),describe("extractArtifactFiles",()=>{it("reads v1.0 flat file parts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{url:"beeos-file://file-1",filename:"screenshot.jpeg",mediaType:"image/jpeg"}]}]})).toEqual([{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}])}),it("reads v0.3 nested file parts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{kind:"file",file:{uri:"beeos-file://file-2",name:"capture.png",mimeType:"image/png"}}]}]})).toEqual([{uri:"beeos-file://file-2",name:"capture.png",mimeType:"image/png"}])}),it("does not infer files from text-only beeos-file URI artifacts",()=>{expect(extractArtifactFiles({artifacts:[{parts:[{text:"beeos-file://file-3"}]}]})).toBeUndefined()})}),describe("extractStatusText",()=>{it("reads the agent status message text",()=>{expect(extractStatusText({status:{message:{parts:[{text:"boom"}]}}})).toBe("boom")}),it("returns undefined when no status message",()=>{expect(extractStatusText({status:{}})).toBeUndefined()})}),describe("taskState",()=>{it("normalizes v1.0 SCREAMING_SNAKE states",()=>{expect(taskState({status:{state:"TASK_STATE_COMPLETED"}})).toBe("completed"),expect(taskState({status:{state:"TASK_STATE_WORKING"}})).toBe("working")}),it("passes through v0.3 lowercase states",()=>{expect(taskState({status:{state:"failed"}})).toBe("failed")})}),describe("buildTaskPayload",()=>{it("surfaces produced content with the terminal state (canceled with artifact)",()=>{expect(buildTaskPayload({contextId:"ctx-1",status:{state:"TASK_STATE_CANCELED"},artifacts:[{parts:[{text:"partial work"}]}]},"t1","agent-1")).toEqual({status:"canceled",task_id:"t1",agent_id:"agent-1",context_id:"ctx-1",is_terminal:!0,result:"partial work"})}),it("reports status-message text as error when there is no artifact",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_FAILED",message:{parts:[{text:"boom"}]}}},"t2","agent-2")).toEqual({status:"failed",task_id:"t2",agent_id:"agent-2",is_terminal:!0,error:"boom"})}),it("marks a working task as non-terminal with neither result nor error",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_WORKING"}},"t3","agent-3")).toEqual({status:"working",task_id:"t3",agent_id:"agent-3",is_terminal:!1})}),it("includes result for a completed task",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"the answer"}]}]},"t4","agent-4")).toEqual({status:"completed",task_id:"t4",agent_id:"agent-4",is_terminal:!0,result:"the answer"})}),it("returns result text and structured files together",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"beeos-file://file-1"},{url:"beeos-file://file-1",filename:"screenshot.jpeg",mediaType:"image/jpeg"}]}]},"t5","agent-5")).toEqual({status:"completed",task_id:"t5",agent_id:"agent-5",is_terminal:!0,result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}),it("does not turn text-only beeos-file URI results into files",()=>{expect(buildTaskPayload({status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"beeos-file://file-only-text"}]}]},"t6","agent-6")).toEqual({status:"completed",task_id:"t6",agent_id:"agent-6",is_terminal:!0,result:"beeos-file://file-only-text"})})}),describe("beeos_call_agent async create",()=>{afterEach(()=>{vi.unstubAllGlobals()}),it("creates the task and returns the handle (task_id, agent_id, context_id) without polling or task data",async()=>{const t=[],e=vi.fn(async(e,s)=>{const a=s?.body?JSON.parse(s.body):{};if(t.push({url:e,method:s?.method,rpcMethod:a.method}),"SendMessage"===a.method)return jsonResponse({jsonrpc:"2.0",id:a.id,result:{task:{id:"task-async-1",contextId:"c1",status:{state:"TASK_STATE_WORKING"}}}});throw new Error(`unexpected request method: ${a.method}`)});vi.stubGlobal("fetch",e);const s=registerTools(makeCfg()).get("beeos_call_agent"),a=await s.execute("tc-1",{agentId:AGENT_ID,message:"do it"}),o=JSON.parse(a.content);expect(o.status).toBe("working"),expect(o.task_id).toBe("task-async-1"),expect(o.agent_id).toBe(AGENT_ID),expect(o.context_id).toBe("c1"),expect(o.result).toBeUndefined(),expect(t).toHaveLength(1),expect(t[0].rpcMethod).toBe("SendMessage"),expect(t[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`)}),it("registers the task so beeos_get_task can resolve the agent id",async()=>{const t=vi.fn(async(t,e)=>{const s=e?.body?JSON.parse(e.body):{};return"SendMessage"===s.method?jsonResponse({jsonrpc:"2.0",id:s.id,result:{task:{id:"task-reg-1",status:{state:"TASK_STATE_WORKING"}}}}):jsonResponse({jsonrpc:"2.0",id:s.id,result:{id:"task-reg-1",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"done"}]}]}})});vi.stubGlobal("fetch",t);const e=new TaskHandleRegistry,s=registerTools(makeCfg(),e);await s.get("beeos_call_agent").execute("tc",{agentId:AGENT_ID,message:"do it"});const a=await s.get("beeos_get_task").execute("tc",{taskId:"task-reg-1"}),o=JSON.parse(a.content);expect(o.status).toBe("completed"),expect(o.result).toBe("done"),expect(o.agent_id).toBe(AGENT_ID)}),it("returns an error handle (with agent_id, no task_id) when create fails",async()=>{const t=vi.fn(async(t,e)=>jsonResponse({jsonrpc:"2.0",id:(e?.body?JSON.parse(e.body):{}).id,error:{code:-32e3,message:"boom"}},!1,500));vi.stubGlobal("fetch",t);const e=registerTools(makeCfg()).get("beeos_call_agent"),s=await e.execute("tc",{agentId:AGENT_ID,message:"do it"}),a=JSON.parse(s.content);expect(a.error).toContain("send task failed"),expect(a.agent_id).toBe(AGENT_ID),expect(a.task_id).toBeUndefined()})}),describe("beeos_get_task",()=>{function t(t){const e=[],s=vi.fn(async(s,a)=>{const o=a?.body?JSON.parse(a.body):{};if(e.push({url:s,method:a?.method,rpcMethod:o.method,params:o.params}),"GetTask"!==o.method)throw new Error(`unexpected method: ${o.method}`);return jsonResponse({jsonrpc:"2.0",id:o.id,result:t})});return vi.stubGlobal("fetch",s),e}afterEach(()=>{vi.unstubAllGlobals()}),it("describes structured files as runtime-presented artifacts, not Markdown or MEDIA",()=>{const t=registerTools(makeCfg()).get("beeos_get_task").description??"";expect(t).toContain("runtime displays image files"),expect(t).toContain("chat attachments"),expect(t).toContain("Do not output MEDIA:"),expect(t).not.toContain("![")}),it("returns the data of a completed task and queries the right endpoint",async()=>{const e=t({id:"task-1",contextId:"ctx-1",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"the answer"}]}]}),s=registerTools(makeCfg()).get("beeos_get_task"),a=await s.execute("tc",{taskId:"task-1",agentId:AGENT_ID}),o=JSON.parse(a.content);expect(o.status).toBe("completed"),expect(o.task_id).toBe("task-1"),expect(o.agent_id).toBe(AGENT_ID),expect(o.context_id).toBe("ctx-1"),expect(o.is_terminal).toBe(!0),expect(o.result).toBe("the answer"),expect(e).toHaveLength(1),expect(e[0].method).toBe("POST"),expect(e[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`),expect(e[0].params.id).toBe("task-1")}),it("surfaces partial output AND the terminal state for a canceled task that still produced an artifact",async()=>{t({id:"task-cx",status:{state:"TASK_STATE_CANCELED"},artifacts:[{parts:[{text:"partial plan"}]}]});const e=registerTools(makeCfg()).get("beeos_get_task"),s=await e.execute("tc",{taskId:"task-cx",agentId:AGENT_ID}),a=JSON.parse(s.content);expect(a.status).toBe("canceled"),expect(a.task_id).toBe("task-cx"),expect(a.result).toBe("partial plan")}),it("returns an error for a terminal task with no produced content",async()=>{t({id:"task-to",status:{state:"TASK_STATE_FAILED",message:{parts:[{text:"boom"}]}}});const e=registerTools(makeCfg()).get("beeos_get_task"),s=await e.execute("tc",{taskId:"task-to",agentId:AGENT_ID}),a=JSON.parse(s.content);expect(a.error).toBe("boom"),expect(a.task_id).toBe("task-to")}),it("reports a still-running task as working",async()=>{t({id:"task-w",status:{state:"TASK_STATE_WORKING"}});const e=registerTools(makeCfg()).get("beeos_get_task"),s=await e.execute("tc",{taskId:"task-w",agentId:AGENT_ID}),a=JSON.parse(s.content);expect(a.status).toBe("working"),expect(a.task_id).toBe("task-w")}),it("resolves agentId from the session registry when omitted",async()=>{const e=new TaskHandleRegistry;e.create("task-reg",AGENT_ID);const s=t({id:"task-reg",status:{state:"TASK_STATE_COMPLETED"},artifacts:[{parts:[{text:"ok"}]}]}),a=registerTools(makeCfg(),e).get("beeos_get_task"),o=await a.execute("tc",{taskId:"task-reg"}),r=JSON.parse(o.content);expect(r.status).toBe("completed"),expect(r.result).toBe("ok"),expect(r.agent_id).toBe(AGENT_ID),expect(s[0].url).toBe(`https://gw.test/api/v1/a2a/${AGENT_ID}`)}),it("errors when agentId is neither passed nor resolvable",async()=>{const t=vi.fn();vi.stubGlobal("fetch",t);const e=registerTools(makeCfg()).get("beeos_get_task"),s=await e.execute("tc",{taskId:"unknown-task"}),a=JSON.parse(s.content);expect(a.error).toContain("agentId is required"),expect(t).not.toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore,normalizeAgentId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.create"===e?{ok:!0,agentId:t.name,name:t.name,workspace:t.workspace}:"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.files.set"===e?{ok:!0,agentId:t.agentId,workspace:"/ws",file:{name:t.name}}:{})},
|
|
1
|
+
import{describe,it,expect,beforeEach,vi}from"vitest";import{AcpGatewayBridgeCore,normalizeAgentId}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function makeBridge(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},n={isConnected:!0,request:vi.fn(async(e,t)=>"agents.create"===e?{ok:!0,agentId:t.name,name:t.name,workspace:t.workspace}:"agents.update"===e?{ok:!0,agentId:t.agentId}:"agents.files.set"===e?{ok:!0,agentId:t.agentId,workspace:"/ws",file:{name:t.name}}:{})},s=vi.fn(async()=>{const e=n.request.mock.calls.find(e=>"agents.create"===e[0]),t=e?e[1].name:void 0;t&&r.setAgentMapping(new Map([[t,{id:`agent_platform_${t}`}]]))}),r=new AcpGatewayBridgeCore({state:t,transport:a,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>n,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:s});return{bridge:r,state:t,transport:a,gateway:n,logger:e,triggerAgentSync:s}}async function callAgentCreate(e,t){const a={jsonrpc:"2.0",id:1,method:"agent/create",params:t};await e.bridge.handleAgentCreate(a),await Promise.resolve();const n=e.transport.sendResult.mock.calls[0]?.[1];if(!n)throw new Error("agent/create did not call sendResult");return n}describe("handleAgentCreate",()=>{let e;beforeEach(()=>{e=makeBridge()}),it("rejects an empty name with InvalidParams",async()=>{await e.bridge.handleAgentCreate({jsonrpc:"2.0",id:1,method:"agent/create",params:{name:" "}}),expect(e.transport.sendAcpError).toHaveBeenCalledTimes(1),expect(e.transport.sendResult).not.toHaveBeenCalled()}),it("synthesizes a random id and re-applies the display name for an ASCII name",async()=>{const t=await callAgentCreate(e,{name:"coder"});expect(t.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(t.agentId).not.toBe("coder");const a=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect((a?.[1]).name).toBe(t.agentId),expect((a?.[1]).workspace).toContain(`workspace-${t.agentId}`);const n=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(n?.[1]).toMatchObject({agentId:t.agentId,name:"coder"}),expect(t.platformAgentId).toBe(`agent_platform_${t.agentId}`)}),it("synthesizes a random id and re-applies the display name for a pure-Chinese name",async()=>{expect(normalizeAgentId("助手")).toBe("main");const t=await callAgentCreate(e,{name:"助手"});expect(t.agentId).not.toBe("main"),expect(t.agentId).toMatch(/^agent-[0-9a-f]{12}$/);const a=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect((a?.[1]).name).toBe(t.agentId);const n=e.gateway.request.mock.calls.find(e=>"agents.update"===e[0]);expect(n?.[1]).toMatchObject({agentId:t.agentId,name:"助手"})}),it("seeds bootstrap files but refuses IDENTITY.md",async()=>{await callAgentCreate(e,{name:"writer",files:{"SOUL.md":"you are a writer","IDENTITY.md":"should be ignored"}});const t=e.gateway.request.mock.calls.filter(e=>"agents.files.set"===e[0]).map(e=>e[1].name);expect(t).toContain("SOUL.md"),expect(t).not.toContain("IDENTITY.md")}),it("passes model and identity emoji/avatar through to agents.create",async()=>{await callAgentCreate(e,{name:"coder",model:"deepseek-chat",identity:{emoji:"🤖",avatar:"https://x/y.png"}});const t=e.gateway.request.mock.calls.find(e=>"agents.create"===e[0]);expect(t?.[1]).toMatchObject({model:"deepseek-chat",emoji:"🤖",avatar:"https://x/y.png"})})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import AdmZip from"adm-zip";import{existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function buildPackage(e){const
|
|
1
|
+
import AdmZip from"adm-zip";import{existsSync,mkdirSync,mkdtempSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";import{afterEach,beforeEach,describe,expect,it,vi}from"vitest";import{AcpGatewayBridgeCore}from"./bridge-core.js";import{AcpGatewaySessionState}from"./session-state.js";function buildPackage(e){const t=new AdmZip,a={schema_version:1,slug:e.slug,display_name:e.displayName,version:"1.0.0",vibe:e.vibe??"",skills:(e.skills??[]).map(e=>({slug:e,version:"1.0.0"}))};t.addFile("manifest.json",Buffer.from(JSON.stringify(a))),t.addFile("workspace/SOUL.md",Buffer.from(`# SOUL of ${e.slug}\n`)),t.addFile("workspace/AGENTS.md",Buffer.from(`# AGENTS of ${e.slug}\n`));for(const a of e.skills??[])t.addFile(`workspace/skills/${a}/SKILL.md`,Buffer.from(`# skill ${a} for ${e.slug}\n`));return t.toBuffer()}function stubFetch(e){vi.stubGlobal("fetch",vi.fn(async()=>{if(Buffer.isBuffer(e)){const t=e.buffer.slice(e.byteOffset,e.byteOffset+e.byteLength);return{ok:!0,status:200,arrayBuffer:async()=>t}}return{ok:!1,status:e.status,arrayBuffer:async()=>new ArrayBuffer(0)}}))}function createHarness(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),a=[],s=new Map,n=e=>{const t=s.get(e);if(!t)return;const a=["# IDENTITY.md","",`- Name: ${t.name}`];t.emoji&&a.push(`- Emoji: ${t.emoji}`),writeFileSync(join(t.workspace,"IDENTITY.md"),a.join("\n")+"\n")},r={isConnected:!0,request:vi.fn(async(e,t)=>{if(a.push({method:e,params:t}),"agents.create"===e){const e=t.workspace;mkdirSync(e,{recursive:!0});const a=String(t.name);return s.set(a,{workspace:e,name:a,emoji:t.emoji?String(t.emoji):void 0}),n(a),{ok:!0,agentId:a,workspace:e}}if("agents.update"===e){const e=String(t.agentId),a=s.get(e);return a&&(t.name&&(a.name=String(t.name)),t.emoji&&(a.emoji=String(t.emoji)),n(e)),{ok:!0}}return"agents.list"===e?{agents:[]}:{ok:!0}})},i={sendResult:vi.fn(),sendAcpError:vi.fn(),sendSessionUpdate:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},o=vi.fn(async()=>{});return{bridge:new AcpGatewayBridgeCore({state:t,transport:i,config:{gateway:{agentId:"main"}},logger:e,writeObs:vi.fn(),getGateway:()=>r,instanceId:"test-instance",thirdPartyChannels:new Set,triggerAgentSync:o}),gateway:r,transport:i,calls:a}}function applyMsg(e,t){return{jsonrpc:"2.0",id:e,method:"agent/applyTemplate",params:t}}let home;beforeEach(()=>{home=mkdtempSync(join(tmpdir(),"applytpl-home-")),process.env.OPENCLAW_HOME=home}),afterEach(()=>{vi.unstubAllGlobals(),delete process.env.OPENCLAW_HOME,rmSync(home,{recursive:!0,force:!0})}),describe("agent/applyTemplate — live download → unzip → merge into isolated workspace",()=>{it("materializes bootstrap + skills into a unique workspace-<agentId> folder",async()=>{stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",vibe:"法条不瞎背",skills:["legal-research","case-analysis"]}));const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://example.com/p.zip",identity:{emoji:"⚖️"}})),expect(t.sendAcpError).not.toHaveBeenCalled();const a=t.sendResult.mock.calls[0][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.workspace).toBe(join(home,`workspace-${a.agentId}`)),expect(readFileSync(join(a.workspace,"SOUL.md"),"utf8")).toContain("SOUL of litigation-advisor"),expect(existsSync(join(a.workspace,"AGENTS.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/case-analysis/SKILL.md"))).toBe(!0);const s=readFileSync(join(a.workspace,"IDENTITY.md"),"utf8");expect(s).toContain("诉讼纠纷顾问"),expect(s).toContain("⚖️")}),it("keeps multiple agents in separate folders with no cross-contamination",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"litigation-advisor",displayName:"诉讼纠纷顾问",skills:["legal-research"]})),await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"tax-advisor",displayName:"财税顾问",skills:["tax-filing"]})),await e.handleRequest(applyMsg(2,{name:"财税顾问",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.workspace).not.toBe(s.workspace),expect(existsSync(join(a.workspace,"skills/legal-research/SKILL.md"))).toBe(!0),expect(existsSync(join(a.workspace,"skills/tax-filing/SKILL.md"))).toBe(!1),expect(existsSync(join(s.workspace,"skills/tax-filing/SKILL.md"))).toBe(!0),expect(existsSync(join(s.workspace,"skills/legal-research/SKILL.md"))).toBe(!1)}),it("allows duplicate names: same name yields distinct random ids and folders",async()=>{const{bridge:e,transport:t}=createHarness();stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(1,{name:"researcher",packageUrl:"https://x/a.zip"}));const a=t.sendResult.mock.calls[0][1];stubFetch(buildPackage({slug:"researcher",displayName:"Researcher",skills:["search"]})),await e.handleRequest(applyMsg(2,{name:"researcher",packageUrl:"https://x/b.zip"}));const s=t.sendResult.mock.calls[1][1];expect(a.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(s.agentId).toMatch(/^agent-[0-9a-f]{12}$/),expect(a.agentId).not.toBe(s.agentId),expect(a.workspace).not.toBe(s.workspace)}),it("rejects a malformed package without creating an agent (fail-fast)",async()=>{stubFetch(Buffer.from("this is not a zip"));const{bridge:e,transport:t,calls:a}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/bad.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled(),expect(a.find(e=>"agents.create"===e.method)).toBeUndefined()}),it("surfaces a download failure as an error",async()=>{stubFetch({ok:!1,status:403});const{bridge:e,transport:t}=createHarness();await e.handleRequest(applyMsg(1,{name:"诉讼纠纷顾问",packageUrl:"https://x/denied.zip"})),expect(t.sendResult).not.toHaveBeenCalled(),expect(t.sendAcpError).toHaveBeenCalled()})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload,stripTransportMetadata,isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../delegation/mention-resolver.js";import{EXTERNAL_PROTOCOL_TAGS}from"./external-protocol-tags.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,loadHistoryPage,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";import{parseAndValidateTemplatePackage,TemplatePackageError,MAX_ZIP_BYTES}from"./agent-template-package.js";const AGENT_VALID_ID_RE=/^[a-z0-9][a-z0-9_-]{0,63}$/i,AGENT_INVALID_CHARS_RE=/[^a-z0-9_-]+/g,AGENT_LEADING_DASH_RE=/^-+/,AGENT_TRAILING_DASH_RE=/-+$/,DEFAULT_AGENT_ID="main";export function normalizeAgentId(e){const t=(e??"").trim();return t?AGENT_VALID_ID_RE.test(t)?t.toLowerCase():t.toLowerCase().replace(AGENT_INVALID_CHARS_RE,"-").replace(AGENT_LEADING_DASH_RE,"").replace(AGENT_TRAILING_DASH_RE,"").slice(0,64)||"main":"main"}function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i,HISTORY_PAGE_DEFAULT_LIMIT=100,HISTORY_PAGE_MAX_LIMIT=1e3;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",...EXTERNAL_PROTOCOL_TAGS];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const n=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return n&&!n.includes("/")&&SLASH_COMMAND_NAME_RE.test(n)?n.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`,n=stripBeeosChannelTag(e);return n?`${n}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/g,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}class HistoryUpdateRecorder{transport;updates=[];constructor(e){this.transport=e}sendSessionUpdate(e,t,s){const n=this.transport.materializeSessionUpdate(t,s);return null!==n&&this.updates.push(n),!0}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;triggerAgentSync;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];_mappingUpdateCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[];for(const t of this._mappingUpdateCallbacks)t(e)}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}onAgentMappingUpdated(e){this._mappingUpdateCallbacks.push(e),this.agentMapping&&this.agentMapping.size>0&&e(this.agentMapping)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.triggerAgentSync=e.triggerAgentSync,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const n=s?.turnId;if(!n)return;const r=this.state.sessions.get(e);r&&recordTurnIdForRole(r,t,n)})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let n=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const r=t.label||t.derivedTitle||t.title||void 0,i=r&&!isPlaceholderTitle(r)?r:void 0,o=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:i});this.registerSession(o,{ensureVerbose:!1}),n++}n>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${n} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"agent/create":await this.handleAgentCreate(t);break;case"agent/update":await this.handleAgentUpdate(t);break;case"agent/delete":await this.handleAgentDelete(t);break;case"agent/applyTemplate":await this.handleApplyTemplate(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"_beeos/session/history"===e.method?await this.handleSessionHistoryPage(e):"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.handleSessionCancel(void 0,e.params);break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,n=this.state.resolveSessionFromParams(t);if(!n){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}n.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",n=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(n,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:n.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:n.agentId,platformAgentId:n.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const n=this.state.sessionsByGatewayKey.get(t);if(!n)return;const r=e.message?.role,i="user"===r?"user":"system"===r?"system":"assistant",o=n.lastTurnIdByRole?.[i];if(!o)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${i} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:o};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"message_id_bind",role:i,messageId:s,...l?{timestamp:l}:{}},d),n.lastTurnIdByRole&&(n.lastTurnIdByRole[i]=void 0),"assistant"===i&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:n,turnId:o,messageId:s,runId:void 0})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const n=s.sessions;if(!Array.isArray(n))return;for(const t of n){const s=t,n=s.sessionKey??s.key??s.id??s.name;if(n&&"string"==typeof n&&deriveSessionId(n,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,r=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(r),r}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),n=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${n}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),n=s.sessionId;this.state.lastActiveSessionId=n,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:n}),this.onSessionLoaded?.(n,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const n=this.getGateway(),r=e.assistantMq&&!e.assistantMq.closed,i=r?null:n,o=await loadHistory(i,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(o),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,g=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${o.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${g}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const n=stripTransportMetadata(t.content).trim().slice(0,64).replace(/\n/g," ").trim();if(n){e.title=n,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:n}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let h=-1;if(r)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){h=e;break}const{replayedToolCallIds:m,streamCompleteCount:u}=await this.replayHistoryTurnsToSink(e,a,this.transport,{mqActive:Boolean(r),lastAsstIdx:h,turnIdFor:(e,t)=>p(t)});for(const e of m)t.add(e);this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${u} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}async replayHistoryTurnsToSink(e,t,s,n){const r=e.sessionId,i=new Set;let o=0;for(let a=0;a<t.length;a++){const l=t[a],d=n.turnIdFor(l,a);if(n.mqActive&&a===n.lastAsstIdx)this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] assistant — mq active, deferring to emitCurrentStreamSnapshot`);else if("user"!==l.role||"internal_system"!==l.provenance?.kind)if("user"===l.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(l.content))this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] system-injected user message`);else if("user"===l.role&&isHeartbeatPromptText(l.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);else if("user"===l.role&&isCronPromptText(l.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] cron-triggered user message sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);else{if("assistant"===l.role){const t=classifyHeartbeatFilter(l.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);continue}}if("system"!==l.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${a}] role=${l.role} contentLen=${l.content.length} content=${JSON.stringify(l.content.slice(0,80))} tcCount=${l.toolCalls?.length??0}`),"user"===l.role)this.replayUserTurnAsChunks(s,r,l.content,l.metadata,l.timestamp,d,l._seq);else{if(await this.replayAssistantTurnAsStream(s,r,l,d),l.toolCalls)for(const e of l.toolCalls)e.id&&i.add(e.id);s.sendSessionUpdate(r,{sessionUpdate:"stream_complete",stopReason:"end_turn"},{turnId:d}),o+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${a}] contentLen=${l.content.length}`),s.sendSessionUpdate(r,{sessionUpdate:"system_message_chunk",content:{type:"text",text:l.content},metadata:l.metadata,...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}})}else this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] internal_system user message`)}return{replayedToolCallIds:i,streamCompleteCount:o}}async handleSessionHistoryPage(e){const t=e.params??{},s="string"==typeof t.sessionId?t.sessionId:"";if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const n="string"==typeof t.cursor&&t.cursor.length>0?t.cursor:void 0,r="number"==typeof t.limit&&Number.isFinite(t.limit)&&t.limit>0?Math.min(1e3,Math.floor(t.limit)):100,{session:i}=await this.resolveOrCreateSession(s),o=this.getGateway(),a=await loadHistoryPage(o,i.gatewaySessionKey,{...n?{cursor:n}:{},limit:r,logger:this.logger}),l=groupIntoTurns(a.messages),d=new HistoryUpdateRecorder(this.transport);await this.replayHistoryTurnsToSink(i,l,d,{mqActive:!1,lastAsstIdx:-1,turnIdFor:(e,t)=>void 0!==e._seq?`history:${i.gatewaySessionKey}:seq:${e._seq}`:`history:${i.gatewaySessionKey}:seq:none:${n??"head"}:${t}`}),this.logger.debug?.(`[bridge-core] _beeos/session/history sid=${i.sessionId} cursor=${n??"<head>"} limit=${r} turns=${l.length} updates=${d.updates.length} hasMore=${a.hasMore} nextCursor=${a.nextCursor??"<none>"}`),this.transport.sendResult(e.id,{updates:d.updates,...a.nextCursor?{nextCursor:a.nextCursor}:{},hasMore:a.hasMore})}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const n=e.assistantMq;if(n&&!n.closed){const s=n.peek(0);let r=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const n=s[e],i=n.sessionUpdate,o=n.toolCallId;if("tool_call"!==i&&"tool_call_update"!==i||!o||!t.has(o)){if("agent_message_chunk"===i){if(""===(n.content?.text??"").trim()&&e<s.length-1){const n=s[e+1];if(("tool_call"===n.sessionUpdate||"tool_call_update"===n.sessionUpdate)&&n.toolCallId&&t.has(n.toolCallId)){r=e+1;continue}}break}break}r=e+1}const i=resolveTurnId(e),o=i?{turnId:i}:void 0;for(let t=r;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],o)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,n=s.sessionKey??s.key??s.id??s.name;if(!n||"string"!=typeof n)continue;if(isBackgroundSessionKey(n))continue;if(this.cronIsolatedSessionKeys.has(n))continue;const r=deriveSessionId(n,this.instanceId),i=t.find(e=>e.sessionId===n||e.sessionId===r||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===n),o=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=o&&!isPlaceholderTitle(o)?o:void 0,l=s.model,d=s.modelProvider;if(i){if(a&&isPlaceholderTitle(i.title)){i.title=a;const e=this.state.sessions.get(i.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(i.updatedAt=e),l&&(i.model=l),d&&(i.modelProvider=d)}else{const e=createSessionObject({gatewayKey:n,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(n),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const n=s.slice(0,64),r=this.state.sessions.get(t);if(!r)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);r.title=n,r.titleSource="manual";const i=r.gatewaySessionKey,o=this.getGateway();o?.isConnected&&o.sessionsPatch(i,{label:n}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:n})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,n){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const r=this.matchTitleTemplate(s);if(r){e.title=r,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:r})}const i=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),o=process.env.AROUTER_API_KEY||"";if(!i||!o)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(n)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${n.length}`),fetch(`${i}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const n=await s.json(),r=n.choices?.[0]?.message?.content?.trim();if(!r)return;const i=r.replace(/^```json\s*/,"").replace(/```$/,"").trim();let o;try{o=JSON.parse(i).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${i}`)}if(!o)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${o}"`),e.title=o,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:o}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:o})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const n=this.getGateway();if(!n?.isConnected)return;let r=s;if(!r)try{const t=await loadHistory(n,e.gatewaySessionKey);r=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const i=r.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!i)return;const o=r.indexOf(i),a=r.slice(o+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(i.content).trim(),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const n=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${n} gwConnected=${!!this.getGateway()?.isConnected}`);let r=!1;const i=this.getGateway();if(i?.isConnected&&n)try{await i.sessionsDelete(n),r=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${n}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${n}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await i.sessionsReset(n),r=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${n}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${n}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else n&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${n}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const o=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),o.add(e));for(const[e,t]of this.state.toolCallIdMap)o.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(n),this.transport.resetSessionUpdateCount(t),r&&this.onSessionDeleted?.(n),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${n} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const n=this.getGateway(),r=n?.isConnected;if(s.promptDone||void 0===s.pendingPromptId&&void 0===s.currentRunId||(this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`),r&&(s.currentRunId||s.gatewayRequestId)&&n.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),r&&s.gatewaySessionKey)try{await n.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${r}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,n=t?this.state.sessions.get(t):void 0,r=this.getGateway();if(n&&r?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await r.sessionsPatch(n.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,n=t?this.state.sessions.get(t):void 0,r=this.getGateway();let i=s??null,o=null;if(n&&r?.isConnected&&s)try{const e=await r.sessionsPatch(n.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(i=t.model),t?.modelProvider&&(o=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:i,modelProvider:o})}async createAgentShell(e,t,s){const n=this.getGateway();if(!n?.isConnected)throw new Error("gateway not connected");let r,i=normalizeAgentId(e),o=e;"main"===i&&(i=`agent-${randomUUID().slice(0,8)}`,o=i,r=e);const a=join(this.getOpenclawHome(),`workspace-${i}`),l=await n.request("agents.create",{name:o,workspace:a,...s?{model:s}:{},...t?.emoji?{emoji:t.emoji}:{},...t?.avatar?{avatar:t.avatar}:{}}),d=l?.agentId??i,c=l?.workspace??a;if(r)try{await n.request("agents.update",{agentId:d,name:r})}catch(e){this.logger.warn?.(`[bridge-core] createAgentShell: failed to set display name for "${d}": ${e instanceof Error?e.message:e}`)}return{agentId:d,workspace:c}}async handleAgentCreate(e){const t=e.params,s=(t?.name??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});const n=this.getGateway();if(n?.isConnected)try{const{agentId:r,workspace:i}=await this.createAgentShell(s,t?.identity,t?.model),o=t?.files??{};for(const[e,t]of Object.entries(o))if("IDENTITY.md"!==e){if("string"==typeof t)try{await n.request("agents.files.set",{agentId:r,name:e,content:t})}catch(t){this.logger.warn?.(`[bridge-core] agent/create: failed to write "${e}" for "${r}": ${t instanceof Error?t.message:t}`)}}else this.logger.warn?.("[bridge-core] agent/create: ignoring IDENTITY.md in files (identity is set via structured fields)");this.logger.info?.(`[bridge-core] Created agent "${r}" (name="${s}") workspace=${i}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/create: agent sync after create failed: ${e instanceof Error?e.message:e}`)}const a=this.agentMapping?.get(r)?.id??"";this.transport.sendResult(e.id,{agentId:r,platformAgentId:a,workspace:i})}catch(t){this.logger.error?.(`[bridge-core] Failed to create agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Create agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleApplyTemplate(e){const t=e.params,s=(t?.name??"").trim(),n=(t?.packageUrl??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});if(!n)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"packageUrl"});const r=this.getGateway();if(r?.isConnected)try{const r=await fetch(n);if(!r.ok)throw new TemplatePackageError(`failed to download package: HTTP ${r.status}`);const i=Buffer.from(await r.arrayBuffer());if(i.length>MAX_ZIP_BYTES)throw new TemplatePackageError(`package too large: ${i.length} bytes (max ${MAX_ZIP_BYTES})`);const o=t?.sha256?.trim().toLowerCase();if(o){if(createHash("sha256").update(i).digest("hex")!==o)throw new TemplatePackageError("package sha256 mismatch")}const a=parseAndValidateTemplatePackage(i),{agentId:l,workspace:d}=await this.createAgentShell(s,t?.identity,t?.model);let c=0;for(const[e,t]of a.workspaceFiles){const s=resolve(d,e),n=relative(d,s);if(""===n||n.startsWith("..")||path.isAbsolute(n))throw new TemplatePackageError(`refusing to write outside workspace: ${e}`);await mkdir(path.dirname(s),{recursive:!0}),await writeFile(s,t),c++}const g=a.manifest?.slug??"(no manifest)",p=a.manifest?.version??"";this.logger.info?.(`[bridge-core] agent/applyTemplate: applied "${g}${p?`@${p}`:""}" to agent "${l}" (${c} files) workspace=${d}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/applyTemplate: agent sync after apply failed: ${e instanceof Error?e.message:e}`)}const h=this.agentMapping?.get(l)?.id??"";this.transport.sendResult(e.id,{agentId:l,platformAgentId:h,workspace:d})}catch(t){if(t instanceof TemplatePackageError)return this.logger.warn?.(`[bridge-core] agent/applyTemplate: invalid package: ${t.message}`),void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:`Invalid template package: ${t.message}`});this.logger.error?.(`[bridge-core] Failed to apply template for "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Apply template failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentUpdate(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});const n=t?.name?.trim(),r=t?.model?.trim(),i=t?.identity?.emoji?.trim(),o=t?.identity?.avatar?.trim();if(!(n||r||i||o))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"No updatable fields provided (name/model/identity)"});const a=this.getGateway();if(a?.isConnected)try{const t=await a.request("agents.update",{agentId:s,...n?{name:n}:{},...r?{model:r}:{},...i?{emoji:i}:{},...o?{avatar:o}:{}}),l=t?.agentId??s;this.logger.info?.(`[bridge-core] Updated agent "${l}"${n?` name="${n}"`:""}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/update: agent sync after update failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:l})}catch(t){this.logger.error?.(`[bridge-core] Failed to update agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentDelete(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});if("main"===normalizeAgentId(s))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:'"main" cannot be deleted; delete the instance instead'});const n=this.getGateway();if(!n?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const r=t?.deleteFiles??!0;try{const t=await n.request("agents.delete",{agentId:s,deleteFiles:r}),i=t?.agentId??s;this.logger.info?.(`[bridge-core] Deleted agent "${i}" (deleteFiles=${r})`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/delete: agent sync after delete failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:i,..."number"==typeof t?.removedBindings?{removedBindings:t.removedBindings}:{}})}catch(t){this.logger.error?.(`[bridge-core] Failed to delete agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Delete agent failed: ${t instanceof Error?t.message:String(t)}`})}}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=e.params?.agentId,s=await this.fetchSimplifiedSkills(t);this.transport.sendResult(e.id,{skills:s})}async fetchSimplifiedSkills(e){const t=this.getGateway();if(!t?.isConnected)return[];try{const s=await t.skillsStatus(e);if(!s?.skills)return[];const n=s.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),r=await this.getSkillsDir(e);for(const e of n){const t=e.skillKey??e.name;try{const s=await readFile(join(r,t,".beeos-meta.json"),"utf8"),n=JSON.parse(s);n?.iconUrl&&(e.iconUrl=n.iconUrl)}catch{}}return n}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(e){const t=await this.fetchSimplifiedSkills(e);this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:t}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:t.length}})}getOpenclawHome(){return process.env.OPENCLAW_HOME||join(homedir(),".openclaw")}async getSkillsDir(e){const t=this.getOpenclawHome();if(!e||"main"===e)return join(t,"skills");const s=await this.resolveAgentWorkspaceDir(e);return join(s??join(t,`workspace-${e}`),"skills")}async resolveAgentWorkspaceDir(e){const t=this.getGateway();if(!t?.isConnected)return null;try{const s=await t.request("agents.list",{}),n=normalizeAgentId(e),r=s?.agents?.find(e=>normalizeAgentId(e.id)===n);return r?.workspace??null}catch(t){return this.logger.warn?.(`[bridge-core] resolveAgentWorkspaceDir("${e}") failed: ${t instanceof Error?t.message:t}`),null}}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,n=t?.files,r=t?.agentId;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!n?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(await this.getSkillsDir(r),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(n?.length){const e=resolve(o);for(const t of n){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const n=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!n.ok)throw new Error(`Failed to download ${t.path}: ${n.status}`);const r=Buffer.from(await n.arrayBuffer());await writeFile(s,r)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}"${r?` for agent "${r}"`:""} via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList(r)}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params,s=t?.agentId;let n;try{n=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const r=resolveSkillInstallDir(await this.getSkillsDir(s),n),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),n);try{let t=!1;try{const e=await lstat(r);if(e.isSymbolicLink()){const e=await readlink(r);await rm(r,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(r,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${n}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${n}"${s?` for agent "${s}"`:""}`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList(s)}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${n}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,n=t?.enabled,r=t?.agentId;if(!s||"boolean"!=typeof n)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,n,r);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${n}${r?` for agent "${r}"`:""}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:n}),await this.pushSkillsList(r)}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:n,created:r}=await this.resolveOrCreateSession(t);r&&this.onSessionLoaded?.(n.sessionId,n.gatewaySessionKey,n.platformAgentId);const i=n.sessionId;this.state.lastActiveSessionId=i,this.onPromptStart?.();const o=this.getGateway();if(!o?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(n);let a=extractPromptBlocks(sanitizePromptPayload(s));if(!n.title||isPlaceholderTitle(n.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${i} existing="${n.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();n.title=e,n.titleSource="truncated",o?.isConnected&&e&&o.sessionsPatch(n.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(i,{sessionUpdate:"title_update",title:e})}}}const l=buildResolutionPlan(a);if(l.refs.length>0)try{const e=await resolveResources(l,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let d;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{d=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const c=toGatewayPrompt(a,{beeosFileResolutionsByUri:d});if("error"in c)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:c.error});const{message:g,attachments:p}=c,h=resolveStandaloneSlashCommandName(a,g)??resolveSlashCmd(a,g),m="string"==typeof h;if(h&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(h)}catch(e){return t.logger.warn?.(`[bridge-core] /${h} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(n,a,resolveTurnId(n)),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${h}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(n)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const u="chat.send";let f=m?g:withUserMessagePrefix(g);const y=this.config.canvasEnabled&&!m&&!1!==n.canvasActive&&!n.channelType;if(y){const e=n.promptsSinceCanvasInjection??0;!n.canvasPromptInjected||e>=10?(f=`${CANVAS_TERM_MAPPING}\n\n${f}`,n.canvasPromptInjected=!0,n.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):n.promptsSinceCanvasInjection=e+1}if(y){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(n.gatewaySessionKey);f=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${f}`}const t=n.referencedSurfaceId;t&&(f=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${f}`,n.referencedSurfaceId=void 0)}if(!m){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){n.activeMentions=t;f=`${buildMentionInstruction(t)}\n${f}`,this.logger.debug?.(`[bridge-core] delegation mention instruction injected for ${t.length} agent(s)`)}else n.activeMentions=void 0}m&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${i} command=/${h}`),n.promptDone||void 0===n.pendingPromptId&&void 0===n.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${i} oldRunId=${n.currentRunId} oldPromptId=${n.pendingPromptId}`),(n.currentRunId||n.gatewayRequestId)&&o.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId}),this.endRun(n,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(n,i,this.transport),n.pendingPromptId=e.id,n.gatewayMethod=u;const w=resolveTurnId(n);this.emitPromptAsUserSessionUpdates(n,a,w),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},w?{turnId:w}:void 0);const I=extractCronJobId(g);I&&(n.cronJobId=I),this.schedulePromptTimeout(n);const S=p.length>0?p.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,b=`req_${randomUUID().replace(/-/g,"")}`,v={sessionKey:n.gatewaySessionKey,message:f,deliver:!1,idempotencyKey:randomUUID()};S?.length&&(v.attachments=S);if(!o.sendFrame({type:"req",id:b,method:u,params:v}))return this.state.clearPromptTimeout(n),n.pendingPromptId=void 0,n.assistantMq?.close(),n.assistantMq=void 0,n.pendingUserTurnUpdates=void 0,void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});n.gatewayRequestId=b,this.state.promptsByGatewayRequestId.set(b,n)}replayUserTurnAsChunks(e,t,s,n,r,i,o){const a=splitUserReplayBlocks(s),l=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const s of a){let i;if("text"===s.type){if(!s.text)continue;i={type:"text",text:s.text}}else i="resource_link"===s.type?{type:"resource_link",uri:s.uri??"",...s.fileName?{name:s.fileName}:{},...s.mimeType?{mimeType:s.mimeType}:{}}:{type:s.type,...s.mimeType?{mimeType:s.mimeType}:{},...s.fileName?{fileName:s.fileName}:{}};e.sendSessionUpdate(t,{sessionUpdate:"user_message_chunk",content:i,...n?{metadata:n}:{},...r?{timestamp:r}:{}},l)}}async replayAssistantTurnAsStream(e,t,s,n){const r=e=>{if(n||void 0!==e)return{...n?{turnId:n}:{},...void 0!==e?{seq:e}:{}}},i=r(s._seq);if(!s.sourceMessages?.length){const n={sessionUpdate:"history_turn",role:s.role,content:s.content,reasoning:s.reasoning,toolCalls:s.toolCalls,...s.timestamp?{timestamp:s.timestamp}:{}};return s.metadata&&(n.metadata=s.metadata),void e.sendSessionUpdate(t,n,i)}for(const n of s.sourceMessages){const i=n.updateType??"agent_message_chunk",o=r(n._seq);switch(i){case"agent_message_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:n.content},...n.timestamp?{timestamp:n.timestamp}:{},...n.metadata?{metadata:n.metadata}:{}},o);break;case"agent_thought_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n.content},...n.timestamp?{timestamp:n.timestamp}:{}},o);break;case"tool_call":{const r=n.toolCallId??"",i=s.toolCalls?.find(e=>e.id===r),a=n.toolName??"tool",l=n.content??"";let d={};if(l)try{d=JSON.parse(l)}catch{}e.sendSessionUpdate(t,buildToolCallPayload({toolCallId:r,toolName:a,args:d,kind:i?.kind,status:i?.status??"in_progress",timestamp:n.timestamp}),o);break}case"tool_call_update":{const r=n.toolCallId??"",i=s.toolCalls?.find(e=>e.id===r),a=n.toolName??i?.name??"tool",l=n.content??i?.output??"";let d=await readEnrichmentCache(r).catch(()=>{});if(!d){const e=extractBeeosFileUrisFromOutput(l);e.length>0&&(d=e.map(e=>({uri:e,name:"file"})))}if(!d&&"beeos_upload_file"===a){const e=await readUploadToolResult(r).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(a,e).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));t.length>0&&(d=t)}}e.sendSessionUpdate(t,buildToolCallUpdatePayload({toolCallId:r,toolName:a,outputText:l,kind:i?.kind,status:i?.status??"completed",resourceLinks:d,timestamp:n.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const n=(new Date).toISOString(),r=s?{turnId:s}:void 0;for(const i of t){const t=this.toUserSessionUpdateContent(i);if(!t)continue;const o={sessionUpdate:"user_message_chunk",content:t,timestamp:n};s&&void 0===o._turnId&&(o._turnId=s),this.transport.sendSessionUpdate(e.sessionId,o,r),e.pendingUserTurnUpdates?.push(o)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,n=t.mimeType??t.mime_type??e.mimeType??e.mime_type,r=t.text??e.text,i=t.data??e.data,o=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!r&&!i)return;return{type:"resource",resource:{...s?{uri:s}:{},...n?{mimeType:n}:{},...r?{text:r}:{},...i?{data:i}:{},...o?{fileName:o}:{}}}}}handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const n=this.state.sessions.get(s);if(!n)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const r=this.getGateway(),i=r?.isConnected,o=!(!n.currentRunId&&!n.gatewayRequestId);if(i&&o){r.agentCancel(n.gatewaySessionKey,{runId:n.currentRunId,requestId:n.gatewayRequestId})}this.endRun(n,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(n.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(n)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),n=s?{turnId:s}:void 0;e.promptDone=!0;const r=e.currentRunId;if("complete"===t.kind){if(r&&this.onRunComplete){const s=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(r,s,t.stopReason)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},n),this.tryGenerateTitleFromHistory(e,e.sessionId)}else r&&this.onRunFailed&&this.onRunFailed(r,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},n);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0===e.pendingPromptId&&void 0===e.currentRunId||((e.currentRunId||e.gatewayRequestId)&&this.getGateway()?.agentCancel(e.gatewaySessionKey,{runId:e.currentRunId,requestId:e.gatewayRequestId}),this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const n=e.error,r=n?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${r}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,r)}const n=e.payload,r=n?.runId;r?(s.currentRunId=r,s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(r,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${r} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s){const s="string"==typeof t.stopReason?t.stopReason:void 0;this.completePrompt(e,s||"end_turn")}else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),n={...e.params??{}},r="cron.update"===s;!r||n.patch&&"object"==typeof n.patch||(n.patch={});const i=r?n.patch:n,o=i._beeos_meta;let a;if(delete i._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==i._beeos_internal)void 0!==i._beeos_internal&&delete i._beeos_internal;else if(delete i._beeos_internal,i.delivery={mode:"none"},"cron.add"===s){const e=await this.allocateCronChannel(i.name??"cron");e&&(i.description=encodeBeeosChannelTag(i.description,e))}else{const e=n.id,t="description"in i,s=i.description;let r,o=decodeBeeosChannelId(s);!o&&e&&(o=this.cronChannelCache.get(e)),!o&&e&&(r=await this.lookupCronJobDescription(e),o=decodeBeeosChannelId(r));let l=!1;o||(o=await this.allocateCronChannel(i.name??"cron"),l=!0),o&&(t?i.description=encodeBeeosChannelTag(s,o):l&&(i.description=encodeBeeosChannelTag(r,o)),a=o)}try{const r=await t.request(s,n),l="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(r):r;if(this.transport.sendResult(e.id,l??{}),("cron.add"===s||"cron.update"===s)&&r&&"object"==typeof r){const e=r.id;if(e&&void 0!==o?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,o.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&(this.cronOverrides.delete(t),this.cronChannelCache.delete(t))}if(("cron.add"===s||"cron.update"===s)&&r&&"object"==typeof r){const e=r.id,t=decodeBeeosChannelId(i.description)??a;e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const n=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${n}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:n})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const n=this.cronOverrides.get(s);return"number"!=typeof n?e:{...t,_beeos_meta:{maxDurationMs:n}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,n={};for(const[t,r]of Object.entries(e))n[t]=s(r);return{...t,jobs:n}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}async lookupCronJobDescription(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("cron.list",{}),n=s?.jobs;if(!Array.isArray(n))return;for(const t of n){if(!t||"object"!=typeof t)continue;const s=t;if(s.id===e)return"string"==typeof s.description?s.description:void 0}}catch(e){this.logger.debug?.(`[bridge-core] cron.list description lookup failed: ${e instanceof Error?e.message:e}`)}}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const n=new Set,r=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&r.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&n.add(s)}this.cronIsolatedSessionKeys=n,this.cronOverrides.reconcile(r)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let n;for(let r=0;r<=s;r++){const i=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(i.ok)return Buffer.from(await i.arrayBuffer());if(429===i.status||i.status>=500){const o=Number(i.headers.get("retry-after"))||0,a=1e3*Math.max(o,2**r);if(n=new Error(`HTTP ${i.status} from ${e}`),r<s){t.logger?.info?.(`[bridge-core] Download ${i.status}, retry ${r+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${i.status}`)}throw n??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),n=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(n,{recursive:!0}),s.extractAllTo(n,!0);const e=await resolveSkillRoot(n),r=await readdir(e,{withFileTypes:!0});let i=0;for(const s of r){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const n=join(e,s.name),r=join(t,s.name);await rename(n,r).catch(async()=>{await cp(n,r,{recursive:!0})}),i++}if(0===i)throw new Error("Zip archive contained no extractable files")}finally{await rm(n,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),n=join(e,s),r=resolve(e),i=resolve(n),o=relative(r,i);if(!o||".."===o||o.startsWith(`..${sep}`)||path.isAbsolute(o))throw new Error("Invalid skill target path");return n}
|
|
1
|
+
import{randomUUID,createHash}from"node:crypto";import{mkdir,writeFile,readFile,rm,lstat,readlink,stat,readdir,rename,cp}from"node:fs/promises";import path from"node:path";import{join,resolve,relative,sep}from"node:path";import{homedir}from"node:os";import AdmZip from"adm-zip";import{sanitizePromptPayload,stripTransportMetadata,isHeartbeatPromptText,isCronPromptText,extractCronJobId,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{createCronOverridesStore}from"../cron-overrides.js";import{CRON_DURATION_FLOOR_MS}from"../config.js";import{CANVAS_TERM_MAPPING,CANVAS_UPDATE_HINT,buildActiveSurfacesContext}from"../canvas/tools.js";import{parseMentionsParam,buildMentionInstruction}from"../delegation/mention-resolver.js";import{EXTERNAL_PROTOCOL_TAGS}from"./external-protocol-tags.js";import{extractPromptBlocks,toGatewayPrompt,resolveSlashCommandName as resolveSlashCmd}from"./prompt-converter.js";import{loadHistory,loadHistoryPage,groupIntoTurns,extractBeeosFileUrisFromOutput,splitUserReplayBlocks}from"./history-replay.js";import{LocalSessionHistory}from"./local-session-history.js";import{reconcileMissingArtifacts}from"./missing-artifacts-reconciler.js";import{buildResolutionPlan,resolveResources,applyResolutions}from"./prompt-resource-resolver.js";import{readEnrichmentCache,readUploadToolResult}from"../upload/result-cache.js";import{buildToolResultSessionContentBlocks}from"./tool-result-payload-strategies.js";import{prefersChatFileArtifacts}from"./file-artifact-presenter.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{Errors}from"../errors/index.js";import{formatFriendlyError}from"../errors/classify.js";import{resolveChannelFromSessionKey}from"../channel-detection.js";import{resolveTurnId,recordTurnIdForRole}from"./gateway-events.js";import{McpService}from"./mcp-service.js";import{DEFAULT_PROMPT_TIMEOUT_MS}from"./types.js";import{ACP_PROTOCOL_VERSION}from"@beeos-ai/beeos-types/acp";import{parseAndValidateTemplatePackage,TemplatePackageError,MAX_ZIP_BYTES}from"./agent-template-package.js";const AGENT_VALID_ID_RE=/^[a-z0-9][a-z0-9_-]{0,63}$/i,AGENT_INVALID_CHARS_RE=/[^a-z0-9_-]+/g,AGENT_LEADING_DASH_RE=/^-+/,AGENT_TRAILING_DASH_RE=/-+$/,DEFAULT_AGENT_ID="main";export function normalizeAgentId(e){const t=(e??"").trim();return t?AGENT_VALID_ID_RE.test(t)?t.toLowerCase():t.toLowerCase().replace(AGENT_INVALID_CHARS_RE,"-").replace(AGENT_LEADING_DASH_RE,"").replace(AGENT_TRAILING_DASH_RE,"").slice(0,64)||"main":"main"}function narrowContentBlock(e){switch(e.type){case"text":return"string"==typeof e.text?{type:"text",text:e.text}:void 0;case"resource_link":return"string"==typeof e.uri?{type:"resource_link",uri:e.uri,...void 0!==e.name?{name:e.name}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}}:void 0;case"file":return{type:"file",...void 0!==e.text?{text:e.text}:{},...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};case"image":return{type:"image",...void 0!==e.uri?{uri:e.uri}:{},...void 0!==e.mimeType?{mimeType:e.mimeType}:{}};default:return}}const DEFAULT_MODE={id:"default",name:"Default",description:"Default agent mode"},USER_MESSAGE_PREFIX="User Message From BeeOS:",USER_MESSAGE_PREFIX_WITH_NEWLINE=`${USER_MESSAGE_PREFIX}\n`,SLASH_COMMAND_NAME_RE=/^[a-z0-9_-]+$/i,HISTORY_PAGE_DEFAULT_LIMIT=100,HISTORY_PAGE_MAX_LIMIT=1e3;function withUserMessagePrefix(e){return e.startsWith(USER_MESSAGE_PREFIX)?e:`${USER_MESSAGE_PREFIX_WITH_NEWLINE}${e}`}const BACKGROUND_SESSION_PATTERNS=[":cron:",":hook:","node-",...EXTERNAL_PROTOCOL_TAGS];function isBackgroundSessionKey(e){return BACKGROUND_SESSION_PATTERNS.some(t=>e.includes(t))}function isSystemDefaultSessionKey(e){const t=e.split(":");if(t.length<3||"agent"!==t[0])return!1;const s=t.slice(2).join(":");return"main"===s||s===t[1]}function isPlaceholderTitle(e){return!e||"New chat"===e||(!!/^[0-9a-f]{6,12}\s*\(20\d{2}/i.test(e)||(!!/^Sender\s*\(untrusted/i.test(e)||!!/^Conversation\s+info\s*\(untrusted/i.test(e)))}function cleanGatewayDerivedTitle(e){if(!e)return;let t=stripTransportMetadata(e);return/^\[System\b/.test(t)?void 0:(t=t.trim(),t||void 0)}function resolveStandaloneSlashCommandName(e,t){if(0===e.length)return;if(e.some(e=>"text"!==e.type))return;const s=t.trim();if(!s||s.includes("\n")||!s.startsWith("/"))return;const r=s.match(/^\/([^\s:]+)(?::|\s|$)/)?.[1]?.trim();return r&&!r.includes("/")&&SLASH_COMMAND_NAME_RE.test(r)?r.toLowerCase():void 0}export const BEEOS_CHANNEL_TAG_RE=/<!-- beeos:channel=(.+?) -->/;export function encodeBeeosChannelTag(e,t){const s=`\x3c!-- beeos:channel=${t} --\x3e`,r=stripBeeosChannelTag(e);return r?`${r}\n${s}`:s}export function decodeBeeosChannelId(e){if(!e)return;const t=BEEOS_CHANNEL_TAG_RE.exec(e);return t?.[1]}export function stripBeeosChannelTag(e){return e?e.replace(/\n?<!-- beeos:channel=.+? -->/g,"").trim():""}function deriveSessionId(e,t){const s=createHash("sha256").update(`${t}:${e}`).digest("hex").substring(0,32);return`session:${s.slice(0,8)}-${s.slice(8,12)}-${s.slice(12,16)}-${s.slice(16,20)}-${s.slice(20,32)}`}function parseAgentIdFromKey(e){const t=e.split(":");return(t.length>=3&&"agent"===t[0]?t[1]:"")||"main"}function createSessionObject(e){const t=parseAgentIdFromKey(e.gatewayKey);return{sessionId:deriveSessionId(e.gatewayKey,e.instanceId),gatewaySessionKey:e.gatewayKey,agentId:t,platformAgentId:e.resolvePlatformAgentId?.(t),createdAt:Date.now(),chatReplayDedupKeys:new Set,cwd:e.cwd,title:e.title,channelType:resolveChannelFromSessionKey(e.gatewayKey,e.thirdPartyChannels)}}class HistoryUpdateRecorder{transport;updates=[];constructor(e){this.transport=e}sendSessionUpdate(e,t,s){const r=this.transport.materializeSessionUpdate(t,s);return null!==r&&this.updates.push(r),!0}}export class AcpGatewayBridgeCore{state;transport;config;logger;writeObs;getGateway;thirdPartyChannels;beeosFileResolver;agentFetch;agentGatewayUrl;triggerAgentSync;cronChannelCache=new Map;static CRON_CHANNEL_CACHE_MAX=256;localSessionHistory;mcp;initialized=!1;agentMapping=null;_syncResolvers=[];_mappingReadyCallbacks=[];_mappingUpdateCallbacks=[];cronIsolatedSessionKeys=new Set;cronOverrides;setAgentMapping(e){this.agentMapping=e;for(const t of this.state.sessions.values())!t.platformAgentId&&t.agentId&&(t.platformAgentId=e.get(t.agentId)?.id);for(const e of this._syncResolvers)e();this._syncResolvers=[];for(const e of this._mappingReadyCallbacks)e();this._mappingReadyCallbacks=[];for(const t of this._mappingUpdateCallbacks)t(e)}onceAgentMappingReady(e){this.agentMapping&&this.agentMapping.size>0?e():this._mappingReadyCallbacks.push(e)}onAgentMappingUpdated(e){this._mappingUpdateCallbacks.push(e),this.agentMapping&&this.agentMapping.size>0&&e(this.agentMapping)}markAgentSyncDone(){for(const e of this._syncResolvers)e();this._syncResolvers=[]}waitForAgentMapping(e=15e3){return this.agentMapping?Promise.resolve():new Promise(t=>{this._syncResolvers.push(t);const s=setTimeout(()=>{const e=this._syncResolvers.indexOf(t);-1!==e&&this._syncResolvers.splice(e,1),t()},e);"object"==typeof s&&"unref"in s&&s.unref()})}handleTerminalRequest=null;handleTerminalNotification=null;onRunComplete=null;onRunFailed=null;onRunEnded=null;handleSlashCommand=null;onPromptStart=null;instanceId;constructor(e){this.state=e.state,this.transport=e.transport,this.config=e.config,this.logger=e.logger,this.writeObs=e.writeObs,this.getGateway=e.getGateway,this.instanceId=e.instanceId||"unknown",this.thirdPartyChannels=e.thirdPartyChannels??new Set,this.beeosFileResolver=e.beeosFileResolver,this.agentFetch=e.agentFetch,this.agentGatewayUrl=e.agentGatewayUrl,this.triggerAgentSync=e.triggerAgentSync,this.localSessionHistory=new LocalSessionHistory({logger:this.logger}),this.cronOverrides=createCronOverridesStore(this.logger),this.mcp=new McpService({transport:this.transport,logger:this.logger,state:this.state}),this.transport.setSessionUpdateObserver((e,t,s)=>{const r=s?.turnId;if(!r)return;const n=this.state.sessions.get(e);n&&recordTurnIdForRole(n,t,r)})}resolvePlatformAgentId(e){return this.agentMapping?.get(e)?.id}getDefaultAgentId(){return this.agentMapping&&this.agentMapping.size>0?this.agentMapping.keys().next().value??"main":"main"}registerSession(e,t={}){const{ensureVerbose:s=!0}=t;this.state.sessions.has(e.sessionId);this.state.sessions.set(e.sessionId,e),this.state.sessionsByGatewayKey.set(e.gatewaySessionKey,e),s&&this.ensureVerboseFull(e),this.subscribeSessionMessages(e)}async ensureVerboseFull(e){if(e.verbosePatched)return;const t=this.getGateway();if(t?.isConnected){e.verbosePatched=!0;try{await t.sessionsPatch(e.gatewaySessionKey,{verboseLevel:"full"})}catch(t){e.verbosePatched=!1,this.logger.debug?.(`[bridge-core] ensureVerboseFull failed: ${t instanceof Error?t.message:t}`)}}}ensureSessionForKey(e){const t=this.state.findSessionByKey(e);if(t)return t;const s=createSessionObject({gatewayKey:e,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels});return this.registerSession(s),this.logger.debug?.(`[bridge-core] ensureSessionForKey: lazily created session sid=${s.sessionId} key=${e}`),s}async preloadSessionsFromGateway(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.sessionsList();if(!t||"object"!=typeof t)return;const s=t.sessions;if(!Array.isArray(s))return;let r=0;for(const e of s){const t=e,s=t.sessionKey??t.key??t.id??t.name;if(!s||"string"!=typeof s)continue;if(this.state.findSessionByKey(s))continue;const n=t.label||t.derivedTitle||t.title||void 0,i=n&&!isPlaceholderTitle(n)?n:void 0,o=createSessionObject({gatewayKey:s,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:i});this.registerSession(o,{ensureVerbose:!1}),r++}r>0&&this.logger.info?.(`[bridge-core] preloadSessions: loaded ${r} session(s) from gateway`)}catch(e){this.logger.debug?.("[bridge-core] preloadSessions failed:",e)}else this.logger.debug?.("[bridge-core] preloadSessions skipped: gateway not connected")}async handleRequest(e){this.writeObs({component:"bridge-core",domain:"forward",name:`rpc.request.${e.method}`,severity:"debug",payload:{id:e.id,method:e.method}});const t=e;try{switch(t.method){case"initialize":this.handleInitialize(t);break;case"ping":this.transport.sendResult(t.id,{pong:!0});break;case"session/new":await this.handleSessionNew(t);break;case"session/load":await this.handleSessionLoad(t);break;case"session/list":await this.handleSessionList(t);break;case"session/rename":this.handleSessionRename(t);break;case"session/delete":await this.handleSessionDelete(t);break;case"session/clear":await this.handleSessionClear(t);break;case"session/prompt":await this.handleSessionPrompt(t);break;case"session/set_mode":await this.handleSessionSetMode(t);break;case"session/set_model":await this.handleSessionSetModel(t);break;case"agent/create":await this.handleAgentCreate(t);break;case"agent/update":await this.handleAgentUpdate(t);break;case"agent/delete":await this.handleAgentDelete(t);break;case"agent/applyTemplate":await this.handleApplyTemplate(t);break;case"skills/list":await this.handleSkillsList(t);break;case"skills/install":await this.handleSkillsInstall(t);break;case"skills/uninstall":await this.handleSkillsUninstall(t);break;case"skills/update":await this.handleSkillsUpdate(t);break;case"models/list":await this.handleModelsList(t);break;case"session/cancel":this.handleSessionCancel(t.id,t.params);break;case"cron/list":case"cron/status":case"cron/add":case"cron/update":case"cron/remove":case"cron/run":case"cron/runs":await this.handleCronRequest(t);break;default:"_beeos/session/history"===e.method?await this.handleSessionHistoryPage(e):"mcp/list"===e.method?await this.mcp.handleList(e):"mcp/set"===e.method?await this.mcp.handleSet(e):"mcp/unset"===e.method?await this.mcp.handleUnset(e):e.method.startsWith("terminal/")&&this.handleTerminalRequest?await this.handleTerminalRequest(e):this.transport.sendAcpError(e.id,Errors.MethodNotFound,{message:`Method not found: ${e.method}`})}}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:t instanceof Error?t.message:"Internal error"})}}handleCanvasNotification=null;getCanvasState=null;onSessionLoaded=null;onSkillInstalled=null;onSessionDeleted=null;onSessionCleared=null;handleNotification(e){switch(e.method){case"session/cancel":this.handleSessionCancel(void 0,e.params);break;case"canvas/toggle":case"canvas/clear":this.handleCanvasNotification?this.handleCanvasNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Canvas notification ignored (canvas disabled): ${e.method}`);break;case"canvas/reference":{const t=e.params,s=t?.surfaceId,r=this.state.resolveSessionFromParams(t);if(!r){this.logger.warn?.(`[bridge-core] canvas/reference ignored — no session found for sessionId=${t?.sessionId}`);break}r.referencedSurfaceId=s||void 0,this.logger.debug?.(`[bridge-core] Canvas reference ${s?"set":"cleared"}: ${s??"(none)"}`);break}default:e.method.startsWith("terminal/")&&this.handleTerminalNotification?this.handleTerminalNotification(e.method,e.params):this.logger.debug?.(`[bridge-core] Unknown notification: ${e.method}`)}}handleInitialize(e){this.initialized=!0;const t=this.transport.forwardControl;!1===e.params?.forwardThinking&&(t.forwardThinking=!1),!1===e.params?.forwardToolCalls&&(t.forwardToolCalls=!1),this.transport.sendResult(e.id,{protocolVersion:ACP_PROTOCOL_VERSION,agentCapabilities:{loadSession:!0,skills:!0,multipleSessions:!0,sessionCapabilities:{"web-ssh":!!this.config.bridge.shell.enabled,canvas:!!this.config.canvasEnabled},promptCapabilities:{image:!0,audio:!1,embeddedContext:!0},mcp:{}},agentInfo:{name:"beeos-claw",title:"BeeOS Agent Bridge",version:"0.1.0"},_meta:{},authMethods:[]})}async handleSessionNew(e){const t=`agent:${"string"==typeof e.params?.agentId&&e.params.agentId||this.config.gateway.agentId||this.getDefaultAgentId()}:${randomUUID()}`,s="string"==typeof e.params?.cwd?e.params.cwd:".",r=createSessionObject({gatewayKey:t,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s});this.registerSession(r,{ensureVerbose:!1}),this.transport.sendResult(e.id,{sessionId:r.sessionId,modes:{availableModes:[{...DEFAULT_MODE}],currentModeId:DEFAULT_MODE.id},_meta:{sessionKey:t,agentId:r.agentId,platformAgentId:r.platformAgentId??null}}),this.fetchHistoryInBackground(t)}fetchHistoryInBackground(e){const t=this.getGateway();t?.isConnected&&loadHistory(t,e,50).catch(e=>{this.logger.debug?.(`[bridge-core] Background history fetch failed: ${e instanceof Error?e.message:e}`)})}async subscribeSessionMessages(e){if(e.messageSubscriptionActive)return;const t=this.getGateway();if(t?.isConnected)try{await t.sessionsMessagesSubscribe(e.gatewaySessionKey),e.messageSubscriptionActive=!0,this.logger.debug?.(`[bridge-core] session.message subscribed sessionKey=${e.gatewaySessionKey}`)}catch(t){this.logger.debug?.(`[bridge-core] session.message subscribe failed key=${e.gatewaySessionKey}: ${t instanceof Error?t.message:t}`)}}async resubscribeAllSessionMessages(){for(const e of this.state.sessions.values())e.messageSubscriptionActive=!1,await this.subscribeSessionMessages(e)}handleSessionMessage(e){const t=e?.sessionKey,s=e?.messageId;if(!t||!s)return;const r=this.state.sessionsByGatewayKey.get(t);if(!r)return;const n=e.message?.role,i="user"===n?"user":"system"===n?"system":"assistant",o=r.lastTurnIdByRole?.[i];if(!o)return void this.logger.debug?.(`[bridge-core] session.message skipped: no lastTurnId role=${i} sessionKey=${t} messageId=${s}`);const a=e.message?.timestamp,l="number"==typeof a&&a>0?new Date(a).toISOString():void 0,d={turnId:o};"number"==typeof e.messageSeq&&(d.seq=e.messageSeq),this.transport.sendSessionUpdate(r.sessionId,{sessionUpdate:"message_id_bind",role:i,messageId:s,...l?{timestamp:l}:{}},d),r.lastTurnIdByRole&&(r.lastTurnIdByRole[i]=void 0),"assistant"===i&&reconcileMissingArtifacts({localHistory:this.localSessionHistory,transport:this.transport,logger:this.logger,writeObs:this.writeObs,isSessionAlive:e=>this.state.sessions.has(e)},{session:r,turnId:o,messageId:s,runId:void 0})}async recoverSessionFromGateway(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.sessionsList();if(!s||"object"!=typeof s)return;const r=s.sessions;if(!Array.isArray(r))return;for(const t of r){const s=t,r=s.sessionKey??s.key??s.id??s.name;if(r&&"string"==typeof r&&deriveSessionId(r,this.instanceId)===e){const e=s.label||s.derivedTitle||s.title||void 0,t=e&&!isPlaceholderTitle(e)?e:void 0,n=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,title:t});return this.registerSession(n),n}}}catch(e){this.logger.debug?.("[bridge-core] recoverSessionFromGateway failed:",e)}}async resolveOrCreateSession(e){let t=this.state.sessions.get(e);if(!t&&e.startsWith("agent:")&&(t=this.state.findSessionByKey(e)),t||e.startsWith("agent:")||(t=await this.recoverSessionFromGateway(e)),t)return{session:t,created:!1};const s=e.startsWith("agent:"),r=this.config.gateway.agentId||this.getDefaultAgentId();return t=createSessionObject({gatewayKey:s?e:`agent:${r}:${e}`,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels}),this.registerSession(t),{session:t,created:!0}}async handleSessionLoad(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const{session:s}=await this.resolveOrCreateSession(t),r=s.sessionId;this.state.lastActiveSessionId=r,s.chatReplayDedupKeys.clear(),s.sessionLoadInProgress=!0;try{const e=await this.emitHistoryTurns(s);this.emitCurrentStreamSnapshot(s,e)}finally{s.sessionLoadInProgress=!1}this.transport.sendResult(e.id,{sessionId:r}),this.onSessionLoaded?.(r,s.gatewaySessionKey,s.platformAgentId),this.pushSkillsList().catch(e=>{this.logger.warn?.(`[bridge-core] pushSkillsList failed: ${e instanceof Error?e.message:e}`)})}async emitHistoryTurns(e){const t=new Set,s=e.sessionId;try{const r=this.getGateway(),n=e.assistantMq&&!e.assistantMq.closed,i=n?null:r,o=await loadHistory(i,e.gatewaySessionKey,100,{localHistory:this.localSessionHistory,agentId:e.agentId,sessionId:e.sessionId,logger:this.logger,emitObs:e=>{this.writeObs({component:"history-replay",domain:"lifecycle",name:e.name,severity:e.severity,payload:e.payload})}}),a=groupIntoTurns(o),l=a.filter(e=>"user"===e.role).length,d=a.filter(e=>"assistant"===e.role).length,c=a.filter(e=>e.toolCalls?.length).length,g=a.reduce((e,t)=>e+(t.toolCalls?.length??0),0);if(this.logger.debug?.(`[bridge-core] handleSessionLoad sid=${s} messages=${o.length} turns=${a.length} userTurns=${l} asstTurns=${d} turnsWithToolCalls=${c} totalToolCalls=${g}`),l>0&&e.pendingUserTurnUpdates&&e.pendingUserTurnUpdates.length>0&&(e.pendingUserTurnUpdates=[]),a.length>0&&isPlaceholderTitle(e.title)&&"manual"!==e.titleSource){const t=a.find(e=>{if("user"!==e.role)return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return t.length>0&&!/^Sender\s*\(untrusted/i.test(t)});if(t?.content){const r=stripTransportMetadata(t.content).trim().slice(0,64).replace(/\n/g," ").trim();if(r){e.title=r,e.titleSource="truncated";const t=this.getGateway();t?.isConnected&&t.sessionsPatch(e.gatewaySessionKey,{label:r}).catch(()=>{}),this.transport.sendSessionUpdate(s,{sessionUpdate:"title_update",title:r}),this.tryGenerateTitleFromHistory(e,s,a)}}}const p=t=>`history:${e.gatewaySessionKey}:${t}`;let h=-1;if(n)for(let e=a.length-1;e>=0;e--)if("assistant"===a[e].role){h=e;break}const{replayedToolCallIds:m,streamCompleteCount:u}=await this.replayHistoryTurnsToSink(e,a,this.transport,{mqActive:Boolean(n),lastAsstIdx:h,turnIdFor:(e,t)=>p(t)});for(const e of m)t.add(e);this.logger.debug?.(`[bridge-core] emitHistoryTurns DONE sid=${s} historyStreamCompleteEmitted=${u} replayedToolCallIds=${t.size}`)}catch(e){this.logger.warn?.("[bridge-core] Failed to load session history:",e)}return t}async replayHistoryTurnsToSink(e,t,s,r){const n=e.sessionId,i=new Set;let o=0;for(let a=0;a<t.length;a++){const l=t[a],d=r.turnIdFor(l,a);if(r.mqActive&&a===r.lastAsstIdx)this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] assistant — mq active, deferring to emitCurrentStreamSnapshot`);else if("user"!==l.role||"internal_system"!==l.provenance?.kind)if("user"===l.role&&/^\s*system\s*\(untrusted[^)]*\)/i.test(l.content))this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] system-injected user message`);else if("user"===l.role&&isHeartbeatPromptText(l.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat cron prompt sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);else if("user"===l.role&&isCronPromptText(l.content))this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] cron-triggered user message sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);else{if("assistant"===l.role){const t=classifyHeartbeatFilter(l.content);if(t.filtered){this.logger.info?.(`[bridge-core] SKIP history_turn[${a}] heartbeat assistant reply reason=${t.reason} sessionId=${e.sessionId} contentLen=${l.content.length} preview="${previewFilteredText(l.content)}"`);continue}}if("system"!==l.role)if(this.logger.debug?.(`[bridge-core] SENDING history_turn[${a}] role=${l.role} contentLen=${l.content.length} content=${JSON.stringify(l.content.slice(0,80))} tcCount=${l.toolCalls?.length??0}`),"user"===l.role)this.replayUserTurnAsChunks(s,n,l.content,l.metadata,l.timestamp,d,l._seq);else{if(await this.replayAssistantTurnAsStream(s,n,l,d),l.toolCalls)for(const e of l.toolCalls)e.id&&i.add(e.id);s.sendSessionUpdate(n,{sessionUpdate:"stream_complete",stopReason:"end_turn"},{turnId:d}),o+=1}else this.logger.debug?.(`[bridge-core] SENDING system_message[${a}] contentLen=${l.content.length}`),s.sendSessionUpdate(n,{sessionUpdate:"system_message_chunk",content:{type:"text",text:l.content},metadata:l.metadata,...l.timestamp?{timestamp:l.timestamp}:{}},{turnId:d,...void 0!==l._seq?{seq:l._seq}:{}})}else this.logger.debug?.(`[bridge-core] SKIP history_turn[${a}] internal_system user message`)}return{replayedToolCallIds:i,streamCompleteCount:o}}async handleSessionHistoryPage(e){const t=e.params??{},s="string"==typeof t.sessionId?t.sessionId:"";if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const r="string"==typeof t.cursor&&t.cursor.length>0?t.cursor:void 0,n="number"==typeof t.limit&&Number.isFinite(t.limit)&&t.limit>0?Math.min(1e3,Math.floor(t.limit)):100,{session:i}=await this.resolveOrCreateSession(s),o=this.getGateway(),a=await loadHistoryPage(o,i.gatewaySessionKey,{...r?{cursor:r}:{},limit:n,logger:this.logger}),l=groupIntoTurns(a.messages),d=new HistoryUpdateRecorder(this.transport);await this.replayHistoryTurnsToSink(i,l,d,{mqActive:!1,lastAsstIdx:-1,turnIdFor:(e,t)=>void 0!==e._seq?`history:${i.gatewaySessionKey}:seq:${e._seq}`:`history:${i.gatewaySessionKey}:seq:none:${r??"head"}:${t}`}),this.logger.debug?.(`[bridge-core] _beeos/session/history sid=${i.sessionId} cursor=${r??"<head>"} limit=${n} turns=${l.length} updates=${d.updates.length} hasMore=${a.hasMore} nextCursor=${a.nextCursor??"<none>"}`),this.transport.sendResult(e.id,{updates:d.updates,...a.nextCursor?{nextCursor:a.nextCursor}:{},hasMore:a.hasMore})}emitCurrentStreamSnapshot(e,t){const s=e.pendingUserTurnUpdates;if(s&&s.length>0)for(const t of s)this.transport.sendSessionUpdate(e.sessionId,t);const r=e.assistantMq;if(r&&!r.closed){const s=r.peek(0);let n=0;if(t&&t.size>0)for(let e=0;e<s.length;e++){const r=s[e],i=r.sessionUpdate,o=r.toolCallId;if("tool_call"!==i&&"tool_call_update"!==i||!o||!t.has(o)){if("agent_message_chunk"===i){if(""===(r.content?.text??"").trim()&&e<s.length-1){const r=s[e+1];if(("tool_call"===r.sessionUpdate||"tool_call_update"===r.sessionUpdate)&&r.toolCallId&&t.has(r.toolCallId)){n=e+1;continue}}break}break}n=e+1}const i=resolveTurnId(e),o=i?{turnId:i}:void 0;for(let t=n;t<s.length;t++)this.transport.sendSessionUpdate(e.sessionId,s[t],o)}}async handleSessionList(e){const t=[];for(const[,e]of this.state.sessions)isBackgroundSessionKey(e.gatewaySessionKey)||this.cronIsolatedSessionKeys.has(e.gatewaySessionKey)||t.push({sessionId:e.sessionId,title:e.title||"New chat",cwd:e.cwd,updatedAt:e.lastStreamActivityTs??e.createdAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(e.gatewaySessionKey)});const s=this.getGateway();if(s?.isConnected)try{const e=await s.sessionsList();if(e&&"object"==typeof e&&Array.isArray(e.sessions)){const s=e.sessions;for(const e of s){const s=e,r=s.sessionKey??s.key??s.id??s.name;if(!r||"string"!=typeof r)continue;if(isBackgroundSessionKey(r))continue;if(this.cronIsolatedSessionKeys.has(r))continue;const n=deriveSessionId(r,this.instanceId),i=t.find(e=>e.sessionId===r||e.sessionId===n||this.state.sessions.get(e.sessionId)?.gatewaySessionKey===r),o=s.label||cleanGatewayDerivedTitle(s.derivedTitle)||s.title||void 0,a=o&&!isPlaceholderTitle(o)?o:void 0,l=s.model,d=s.modelProvider;if(i){if(a&&isPlaceholderTitle(i.title)){i.title=a;const e=this.state.sessions.get(i.sessionId);e&&isPlaceholderTitle(e.title)&&(e.title=a)}const e=s.updatedAt;e&&(i.updatedAt=e),l&&(i.model=l),d&&(i.modelProvider=d)}else{const e=createSessionObject({gatewayKey:r,instanceId:this.instanceId,resolvePlatformAgentId:e=>this.resolvePlatformAgentId(e),thirdPartyChannels:this.thirdPartyChannels,cwd:s.cwd,title:a});this.registerSession(e,{ensureVerbose:!1}),t.push({sessionId:e.sessionId,title:a||"",cwd:s.cwd,updatedAt:s.updatedAt,agentId:e.agentId,platformAgentId:e.platformAgentId,deletable:!isSystemDefaultSessionKey(r),model:l,modelProvider:d})}}}}catch(e){this.logger.debug?.("[bridge-core] sessions.list enrichment failed:",e)}t.sort((e,t)=>(t.updatedAt??0)-(e.updatedAt??0)||e.sessionId.localeCompare(t.sessionId)),this.transport.sendResult(e.id,{sessions:t})}handleSessionRename(e){const t=e.params?.sessionId,s=e.params?.title;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, title"});const r=s.slice(0,64),n=this.state.sessions.get(t);if(!n)return void this.transport.sendAcpError(e.id,Errors.SessionNotFound);n.title=r,n.titleSource="manual";const i=n.gatewaySessionKey,o=this.getGateway();o?.isConnected&&o.sessionsPatch(i,{label:r}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (rename) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendResult(e.id,{sessionId:t,title:r})}static TITLE_TEMPLATES=[{match:["今日日报","日报"],zh:"今日工作日报",en:"Daily Work Log"},{match:["周报","周报总结","本周周报"],zh:"本周工作周报",en:"Weekly Work Report"},{match:["月报","月度汇报","月度总结"],zh:"本月工作月报",en:"Monthly Work Report"},{match:["工作日志","日志"],zh:"工作日志整理",en:"Work Log Review"},{match:["晨会","今日晨会"],zh:"今日晨会纪要",en:"Daily Standup Notes"},{match:["复盘","今日复盘"],zh:"今日工作复盘",en:"Daily Retrospective"},{match:["新会话","new chat","new conversation"],zh:"新会话",en:"New Conversation"}];static TITLE_SYSTEM_PROMPT='You are a conversation title generator. Your task is to generate a concise, human-readable title summarizing the topic of the user\'s question together with the assistant\'s answer.\n\n### Rules:\n- Length: 3-6 words in English, or 6-20 characters in Chinese (up to 24 if needed to keep all key domain terms intact).\n- Language: Match the user\'s input language exactly. Mixed zh/en input → use the dominant language.\n- Extract the core topic (noun-centric) of the Q&A exchange, not the action verb.\n- Use the assistant\'s answer to disambiguate or enrich the topic when the question alone is vague (e.g. "帮我看下" + an answer about x402 → "x402 支付集成").\n- No punctuation, no quotation marks, no emojis, no markdown.\n- No prefix like "Title:" or "Here is". Output the title only.\n- Avoid generic words: "help", "question", "chat", "problem", "task".\n- If both the question and answer are too vague to summarize, return "新会话" (for zh) or "New Conversation" (for en).\n\n### Input Format:\nThe user message will contain both the question and the assistant\'s answer, delimited like this:\n[User Question]\n...question text...\n\n[Assistant Answer]\n...answer text (may be truncated with "..." in the middle)...\n\n### Output Format:\nRespond ONLY with valid JSON, no other text:\n{"title": "<your title here>"}\n\n### Examples:\nInput:\n[User Question]\n帮我写一个关于BeeOS设备接入的PRD\n\n[Assistant Answer]\n好的,这是 BeeOS 设备接入 PRD 的草稿:1. 目标 ...\nOutput: {"title": "BeeOS 设备接入 PRD"}\n\nInput:\n[User Question]\n帮我看下\n\n[Assistant Answer]\n你说的 x402 支付协议是一个基于 HTTP 402 的链上支付标准 ...\nOutput: {"title": "x402 支付协议解析"}\n\nInput:\n[User Question]\nWhat\'s the best way to integrate x402 payment protocol?\n\n[Assistant Answer]\nYou have three options: 1. Use the official SDK ...\nOutput: {"title": "x402 Payment Integration"}\n\nInput:\n[User Question]\nhi\n\n[Assistant Answer]\nHello! How can I help you today?\nOutput: {"title": "New Conversation"}';matchTitleTemplate(e){const t=e.trim().toLowerCase();for(const s of AcpGatewayBridgeCore.TITLE_TEMPLATES)if(s.match.some(e=>e.toLowerCase()===t))return/[\u4e00-\u9fff]/.test(e)?s.zh:s.en}isGuardMessage(e){const t=e.trim();return[...t].length<3||[...t].every(e=>/[\p{P}\p{S}\s]/u.test(e))}truncateQuestionForTitle(e){return e.length>2e3?e.slice(0,1500):e}truncateAnswerForTitle(e){return e.length<=1e3?e:`${e.slice(0,500)}\n...\n${e.slice(-500)}`}generateTitleFromQA(e,t,s,r){if(this.isGuardMessage(s))return void this.logger.info?.(`[bridge-core] title-gen SKIP guard message sid=${t}`);const n=this.matchTitleTemplate(s);if(n){e.title=n,e.titleSource="template";const s=this.getGateway();return s?.isConnected&&s.sessionsPatch(e.gatewaySessionKey,{label:n}).catch(()=>{}),void this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:n})}const i=(process.env.AROUTER_BASE_URL||"").replace(/\/$/,""),o=process.env.AROUTER_API_KEY||"";if(!i||!o)return void this.logger.info?.(`[bridge-core] title-gen SKIP sid=${t}: no AROUTER env vars`);const a=`[User Question]\n${this.truncateQuestionForTitle(s)}\n\n[Assistant Answer]\n${this.truncateAnswerForTitle(r)}`;this.logger.info?.(`[bridge-core] title-gen CALLING LLM sid=${t} q="${s.slice(0,60)}" aLen=${r.length}`),fetch(`${i}/v1/chat/completions`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify({model:"openai/gpt-4o-mini",max_tokens:32,temperature:.3,messages:[{role:"system",content:AcpGatewayBridgeCore.TITLE_SYSTEM_PROMPT},{role:"user",content:a}]})}).then(async s=>{if(!s.ok){const e=await s.text().catch(()=>"");return void this.logger.info?.(`[bridge-core] title-gen FAILED sid=${t} status=${s.status} body=${e.slice(0,300)}`)}const r=await s.json(),n=r.choices?.[0]?.message?.content?.trim();if(!n)return;const i=n.replace(/^```json\s*/,"").replace(/```$/,"").trim();let o;try{o=JSON.parse(i).title?.trim()??""}catch{return void this.logger.debug?.(`[bridge-core] title-gen JSON parse failed: ${i}`)}if(!o)return;if("manual"===e.titleSource)return void this.logger.debug?.("[bridge-core] title-gen discarded: user already renamed");this.logger.info?.(`[bridge-core] title-gen OK sid=${t} title="${o}"`),e.title=o,e.titleSource="auto";const a=this.getGateway();a?.isConnected&&a.sessionsPatch(e.gatewaySessionKey,{label:o}).catch(()=>{}),this.transport.sendSessionUpdate(t,{sessionUpdate:"title_update",title:o})}).catch(e=>{this.logger.info?.(`[bridge-core] title-gen ERROR sid=${t}: ${e instanceof Error?e.message:e}`)})}async tryGenerateTitleFromHistory(e,t,s){if("manual"===e.titleSource||"auto"===e.titleSource)return;const r=this.getGateway();if(!r?.isConnected)return;let n=s;if(!n)try{const t=await loadHistory(r,e.gatewaySessionKey);n=groupIntoTurns(t)}catch(e){return void this.logger.debug?.(`[bridge-core] title-gen loadHistory failed sid=${t}: ${e instanceof Error?e.message:e}`)}const i=n.find(e=>{if("user"!==e.role)return!1;if(!e.content?.trim())return!1;if("internal_system"===e.provenance?.kind)return!1;if(isCronPromptText(e.content))return!1;const t=stripTransportMetadata(e.content).trim();return!!t&&!/^Sender\s*\(untrusted/i.test(t)});if(!i)return;const o=n.indexOf(i),a=n.slice(o+1).find(e=>"assistant"===e.role&&!!e.content?.trim());if(!a)return;const l=stripTransportMetadata(i.content).trim(),d=a.content.trim();l&&d&&this.generateTitleFromQA(e,t,l,d)}async handleSessionDelete(e){const t=e.params?.sessionId;if(!t)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.info?.(`[bridge-core] session/delete sessionId=${t} NOT FOUND in local state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);const r=s.gatewaySessionKey;this.logger.info?.(`[bridge-core] session/delete sessionId=${t} gatewayKey=${r} gwConnected=${!!this.getGateway()?.isConnected}`);let n=!1;const i=this.getGateway();if(i?.isConnected&&r)try{await i.sessionsDelete(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.delete SUCCESS for key=${r}`)}catch(t){const s=t instanceof Error?t.message:String(t);if(this.logger.info?.(`[bridge-core] Gateway sessions.delete FAILED for key=${r}: ${s}`),!s.includes("Cannot delete the main session"))return void this.transport.sendResult(e.id,{deleted:!1});try{await i.sessionsReset(r),n=!0,this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${r}`)}catch(t){return this.logger.warn?.(`[bridge-core] Gateway sessions.reset also failed for key=${r}: ${t instanceof Error?t.message:t}`),void this.transport.sendResult(e.id,{deleted:!1})}}else r&&this.logger.warn?.(`[bridge-core] Gateway not connected, proceeding with local-only delete for key=${r}`);void 0!==s.pendingPromptId&&this.failPrompt(s,Errors.SessionDeleted.rpcCode,Errors.SessionDeleted.message),this.state.clearPromptTimeout(s),this.state.clearStreamIdleTimer(s),s.assistantMq?.close(),s.assistantMq=void 0,s.currentRunId&&this.state.promptsByRunId.delete(s.currentRunId);const o=new Set;for(const[e,s]of this.state.toolCallToSessionMap)s===t&&(this.state.toolCallToSessionMap.delete(e),o.add(e));for(const[e,t]of this.state.toolCallIdMap)o.has(t)&&this.state.toolCallIdMap.delete(e);this.state.sessions.delete(t),this.state.sessionsByGatewayKey.delete(r),this.transport.resetSessionUpdateCount(t),n&&this.onSessionDeleted?.(r),this.logger.info?.(`[bridge-core] session/delete DONE sessionId=${t} gatewayKey=${r} deleted=true`),this.transport.sendResult(e.id,{deleted:!0})}async handleSessionClear(e){this.logger.info?.(`[bridge-core] handleSessionClear called, params=${JSON.stringify(e.params)}`);const t=e.params?.sessionId;if(!t)return this.logger.warn?.("[bridge-core] session/clear missing sessionId"),void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId"});const s=this.state.sessions.get(t);if(!s)return this.logger.warn?.(`[bridge-core] session/clear sessionId=${t} not found in state`),void this.transport.sendAcpError(e.id,Errors.SessionNotFound);this.logger.info?.(`[bridge-core] session/clear sessionId=${t} gatewayKey=${s.gatewaySessionKey}`);const r=this.getGateway(),n=r?.isConnected;if(s.promptDone||void 0===s.pendingPromptId&&void 0===s.currentRunId||(this.logger.info?.(`[bridge-core] session/clear: cancelling active run sid=${t} runId=${s.currentRunId}`),n&&(s.currentRunId||s.gatewayRequestId)&&r.agentCancel(s.gatewaySessionKey,{runId:s.currentRunId,requestId:s.gatewayRequestId}),this.endRun(s,{kind:"complete",stopReason:"cancelled"})),n&&s.gatewaySessionKey)try{await r.sessionsReset(s.gatewaySessionKey),this.logger.info?.(`[bridge-core] Gateway sessions.reset SUCCESS for key=${s.gatewaySessionKey}`)}catch(e){this.logger.warn?.(`[bridge-core] Gateway sessions.reset FAILED for key=${s.gatewaySessionKey}: ${e instanceof Error?e.message:e}`)}else this.logger.warn?.(`[bridge-core] session/clear skipped gateway reset: connected=${n}, key=${s.gatewaySessionKey}`);s.chatReplayDedupKeys.clear(),this.logger.info?.("[bridge-core] session/clear dedupKeys cleared, calling onSessionCleared"),this.onSessionCleared?.(t,s.gatewaySessionKey),this.transport.sendResult(e.id,{cleared:!0}),this.logger.info?.("[bridge-core] session/clear DONE, sent cleared=true")}async handleSessionSetMode(e){const t=e.params?.sessionId,s=e.params?.mode,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();if(r&&n?.isConnected&&s){const e={};if("verbose"===s||"detailed"===s?e.verboseLevel="full":"concise"!==s&&"default"!==s||(e.verboseLevel="default"),"reasoning"!==s&&"thinking"!==s||(e.reasoningLevel="stream"),Object.keys(e).length>0)try{await n.sessionsPatch(r.gatewaySessionKey,e)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch failed: ${e instanceof Error?e.message:e}`)}}this.transport.sendResult(e.id,{mode:s??"default"})}async handleSessionSetModel(e){const t=e.params?.sessionId,s=e.params?.model,r=t?this.state.sessions.get(t):void 0,n=this.getGateway();let i=s??null,o=null;if(r&&n?.isConnected&&s)try{const e=await n.sessionsPatch(r.gatewaySessionKey,{model:s}),t=e?.resolved;t?.model&&(i=t.model),t?.modelProvider&&(o=t.modelProvider)}catch(e){this.logger.warn?.(`[bridge-core] sessions.patch for model failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{model:i,modelProvider:o})}async createAgentShell(e,t,s){const r=this.getGateway();if(!r?.isConnected)throw new Error("gateway not connected");const n=`agent-${randomUUID().replace(/-/g,"").slice(0,12)}`,i=n,o=e,a=join(this.getOpenclawHome(),`workspace-${n}`),l=await r.request("agents.create",{name:i,workspace:a,...s?{model:s}:{},...t?.emoji?{emoji:t.emoji}:{},...t?.avatar?{avatar:t.avatar}:{}}),d=l?.agentId??n,c=l?.workspace??a;if(o)try{await r.request("agents.update",{agentId:d,name:o})}catch(e){this.logger.warn?.(`[bridge-core] createAgentShell: failed to set display name for "${d}": ${e instanceof Error?e.message:e}`)}return{agentId:d,workspace:c}}async handleAgentCreate(e){const t=e.params,s=(t?.name??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});const r=this.getGateway();if(r?.isConnected)try{const{agentId:n,workspace:i}=await this.createAgentShell(s,t?.identity,t?.model),o=t?.files??{};for(const[e,t]of Object.entries(o))if("IDENTITY.md"!==e){if("string"==typeof t)try{await r.request("agents.files.set",{agentId:n,name:e,content:t})}catch(t){this.logger.warn?.(`[bridge-core] agent/create: failed to write "${e}" for "${n}": ${t instanceof Error?t.message:t}`)}}else this.logger.warn?.("[bridge-core] agent/create: ignoring IDENTITY.md in files (identity is set via structured fields)");this.logger.info?.(`[bridge-core] Created agent "${n}" (name="${s}") workspace=${i}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/create: agent sync after create failed: ${e instanceof Error?e.message:e}`)}const a=this.agentMapping?.get(n)?.id??"";this.transport.sendResult(e.id,{agentId:n,platformAgentId:a,workspace:i})}catch(t){this.logger.error?.(`[bridge-core] Failed to create agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Create agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleApplyTemplate(e){const t=e.params,s=(t?.name??"").trim(),r=(t?.packageUrl??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing agent name"});if(!r)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"packageUrl"});const n=this.getGateway();if(n?.isConnected)try{const n=await fetch(r);if(!n.ok)throw new TemplatePackageError(`failed to download package: HTTP ${n.status}`);const i=Buffer.from(await n.arrayBuffer());if(i.length>MAX_ZIP_BYTES)throw new TemplatePackageError(`package too large: ${i.length} bytes (max ${MAX_ZIP_BYTES})`);const o=t?.sha256?.trim().toLowerCase();if(o){if(createHash("sha256").update(i).digest("hex")!==o)throw new TemplatePackageError("package sha256 mismatch")}const a=parseAndValidateTemplatePackage(i),{agentId:l,workspace:d}=await this.createAgentShell(s,t?.identity,t?.model);let c=0;for(const[e,t]of a.workspaceFiles){const s=resolve(d,e),r=relative(d,s);if(""===r||r.startsWith("..")||path.isAbsolute(r))throw new TemplatePackageError(`refusing to write outside workspace: ${e}`);await mkdir(path.dirname(s),{recursive:!0}),await writeFile(s,t),c++}const g=a.manifest?.slug??"(no manifest)",p=a.manifest?.version??"";this.logger.info?.(`[bridge-core] agent/applyTemplate: applied "${g}${p?`@${p}`:""}" to agent "${l}" (${c} files) workspace=${d}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/applyTemplate: agent sync after apply failed: ${e instanceof Error?e.message:e}`)}const h=this.agentMapping?.get(l)?.id??"";this.transport.sendResult(e.id,{agentId:l,platformAgentId:h,workspace:d})}catch(t){if(t instanceof TemplatePackageError)return this.logger.warn?.(`[bridge-core] agent/applyTemplate: invalid package: ${t.message}`),void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:`Invalid template package: ${t.message}`});this.logger.error?.(`[bridge-core] Failed to apply template for "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Apply template failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentUpdate(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});const r=t?.name?.trim(),n=t?.model?.trim(),i=t?.identity?.emoji?.trim(),o=t?.identity?.avatar?.trim();if(!(r||n||i||o))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"No updatable fields provided (name/model/identity)"});const a=this.getGateway();if(a?.isConnected)try{const t=await a.request("agents.update",{agentId:s,...r?{name:r}:{},...n?{model:n}:{},...i?{emoji:i}:{},...o?{avatar:o}:{}}),l=t?.agentId??s;this.logger.info?.(`[bridge-core] Updated agent "${l}"${r?` name="${r}"`:""}`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/update: agent sync after update failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:l})}catch(t){this.logger.error?.(`[bridge-core] Failed to update agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update agent failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleAgentDelete(e){const t=e.params,s=(t?.agentId??"").trim();if(!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"agentId"});if("main"===normalizeAgentId(s))return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:'"main" cannot be deleted; delete the instance instead'});const r=this.getGateway();if(!r?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const n=t?.deleteFiles??!0;try{const t=await r.request("agents.delete",{agentId:s,deleteFiles:n}),i=t?.agentId??s;this.logger.info?.(`[bridge-core] Deleted agent "${i}" (deleteFiles=${n})`);try{await(this.triggerAgentSync?.())}catch(e){this.logger.warn?.(`[bridge-core] agent/delete: agent sync after delete failed: ${e instanceof Error?e.message:e}`)}this.transport.sendResult(e.id,{agentId:i,..."number"==typeof t?.removedBindings?{removedBindings:t.removedBindings}:{}})}catch(t){this.logger.error?.(`[bridge-core] Failed to delete agent "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Delete agent failed: ${t instanceof Error?t.message:String(t)}`})}}async handleModelsList(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("models.list",{});this.transport.sendResult(e.id,s??{models:[]})}catch(t){this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Failed to list models: ${t instanceof Error?t.message:t}`})}else this.transport.sendAcpError(e.id,Errors.GatewayNotConnected)}async handleSkillsList(e){const t=e.params?.agentId,s=await this.fetchSimplifiedSkills(t);this.transport.sendResult(e.id,{skills:s})}async fetchSimplifiedSkills(e){const t=this.getGateway();if(!t?.isConnected)return[];try{const s=await t.skillsStatus(e);if(!s?.skills)return[];const r=s.skills.filter(e=>!1!==e.eligible||!0===e.disabled).sort((e,t)=>e.always&&!t.always?-1:!e.always&&t.always?1:e.name.localeCompare(t.name)).map(e=>({name:e.name,description:e.description,emoji:e.emoji,always:e.always||void 0,bundled:e.bundled||void 0,disabled:e.disabled||void 0,skillKey:e.skillKey||void 0,iconUrl:void 0})),n=await this.getSkillsDir(e);for(const e of r){const t=e.skillKey??e.name;try{const s=await readFile(join(n,t,".beeos-meta.json"),"utf8"),r=JSON.parse(s);r?.iconUrl&&(e.iconUrl=r.iconUrl)}catch{}}return r}catch(e){return this.logger.warn?.(`[bridge-core] Failed to fetch skills: ${e instanceof Error?e.message:e}`),[]}}async pushSkillsList(e){const t=await this.fetchSimplifiedSkills(e);this.transport.sendNotification("session/update",{sessionId:"_system",update:{sessionUpdate:"available_commands_update",commands:t}}),this.writeObs({component:"bridge-core",domain:"lifecycle",name:"skills.pushed",severity:"debug",payload:{count:t.length}})}getOpenclawHome(){return process.env.OPENCLAW_HOME||join(homedir(),".openclaw")}async getSkillsDir(e){const t=this.getOpenclawHome();if(!e||"main"===e)return join(t,"skills");const s=await this.resolveAgentWorkspaceDir(e);return join(s??join(t,`workspace-${e}`),"skills")}async resolveAgentWorkspaceDir(e){const t=this.getGateway();if(!t?.isConnected)return null;try{const s=await t.request("agents.list",{}),r=normalizeAgentId(e),n=s?.agents?.find(e=>normalizeAgentId(e.id)===r);return n?.workspace??null}catch(t){return this.logger.warn?.(`[bridge-core] resolveAgentWorkspaceDir("${e}") failed: ${t instanceof Error?t.message:t}`),null}}async handleSkillsInstall(e){const t=e.params,s=t?.zipUrl,r=t?.files,n=t?.agentId;let i;try{i=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill slug"})}if(!r?.length&&!s)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skill files or zipUrl"});const o=resolveSkillInstallDir(await this.getSkillsDir(n),i);try{if(await mkdir(o,{recursive:!0}),s){const e=await fetchWithRetry(s,{maxRetries:4,logger:this.logger});await extractZipToDir(e,o)}else if(r?.length){const e=resolve(o);for(const t of r){const s=join(o,t.path);if(!resolve(s).startsWith(e+sep))throw new Error(`File path escapes skill directory: ${t.path}`);await mkdir(join(s,".."),{recursive:!0});const r=await fetch(t.downloadUrl,{signal:AbortSignal.timeout(12e4)});if(!r.ok)throw new Error(`Failed to download ${t.path}: ${r.status}`);const n=Buffer.from(await r.arrayBuffer());await writeFile(s,n)}}if(t?.iconUrl)try{await writeFile(join(o,".beeos-meta.json"),JSON.stringify({iconUrl:t.iconUrl}),"utf8")}catch{}this.logger.info?.(`[bridge-core] Installed skill "${i}"${n?` for agent "${n}"`:""} via ${s?"zip":"files"}`),this.transport.sendResult(e.id,{success:!0}),this.onSkillInstalled?.(i,o),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to install skill "${i}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Install failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUninstall(e){const t=e.params,s=t?.agentId;let r;try{r=validateSkillSlug(t?.slug||t?.name||"")}catch{return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Invalid or missing skill name"})}const n=resolveSkillInstallDir(await this.getSkillsDir(s),r),i=resolveSkillInstallDir(join(homedir(),".agents","skills"),r);try{let t=!1;try{const e=await lstat(n);if(e.isSymbolicLink()){const e=await readlink(n);await rm(n,{force:!0}),await rm(e,{recursive:!0,force:!0}),t=!0}else e.isDirectory()&&(await rm(n,{recursive:!0,force:!0}),t=!0)}catch{}try{const e=await lstat(i);(e.isDirectory()||e.isSymbolicLink())&&(await rm(i,{recursive:!0,force:!0}),t=!0)}catch{}t||this.logger.warn?.(`[bridge-core] Skill "${r}" not found in any known location`),this.logger.info?.(`[bridge-core] Uninstalled skill "${r}"${s?` for agent "${s}"`:""}`),this.transport.sendResult(e.id,{success:!0}),await this.pushSkillsList(s)}catch(t){this.logger.error?.(`[bridge-core] Failed to uninstall skill "${r}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Uninstall failed: ${t instanceof Error?t.message:String(t)}`})}}async handleSkillsUpdate(e){const t=e.params,s=t?.skillKey,r=t?.enabled,n=t?.agentId;if(!s||"boolean"!=typeof r)return void this.transport.sendAcpError(e.id,Errors.InvalidParams,{message:"Missing skillKey or enabled"});const i=this.getGateway();if(i?.isConnected)try{const t=await i.skillsUpdate(s,r,n);this.logger.info?.(`[bridge-core] Updated skill "${s}" enabled=${r}${n?` for agent "${n}"`:""}`),this.transport.sendResult(e.id,{ok:t?.ok??!0,skillKey:s,enabled:r}),await this.pushSkillsList(n)}catch(t){this.logger.error?.(`[bridge-core] Failed to update skill "${s}": ${t instanceof Error?t.message:t}`),this.transport.sendAcpError(e.id,Errors.InternalError,{message:`Update failed: ${t instanceof Error?t.message:String(t)}`})}else this.transport.sendAcpError(e.id,Errors.InternalError,{message:"Gateway not connected"})}async handleSessionPrompt(e){const t=e.params?.sessionId,s=e.params?.prompt;if(!t||!s)return void this.transport.sendAcpError(e.id,Errors.MissingParam,{param:"sessionId, prompt"});const{session:r,created:n}=await this.resolveOrCreateSession(t);n&&this.onSessionLoaded?.(r.sessionId,r.gatewaySessionKey,r.platformAgentId);const i=r.sessionId;this.state.lastActiveSessionId=i,this.onPromptStart?.();const o=this.getGateway();if(!o?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);await this.ensureVerboseFull(r);let a=extractPromptBlocks(sanitizePromptPayload(s));const l=a.filter(e=>"text"===e.type&&"text"in e&&"string"==typeof e.text).map(e=>e.text).join("\n");if(r.fileArtifactDisplayMode=prefersChatFileArtifacts(l)?"chat":"auto",!r.title||isPlaceholderTitle(r.title)){const e=a.find(e=>"text"===e.type&&e.text);if(e&&"text"in e&&e.text){const t=stripTransportMetadata(e.text).trim();if(this.logger.info?.(`[bridge-core] title-gen sid=${i} existing="${r.title}" cleanText="${t.slice(0,80)}" isPlaceholder=${isPlaceholderTitle(t)}`),t&&!isPlaceholderTitle(t)){const e=t.slice(0,64).replace(/\n/g," ").trim();r.title=e,r.titleSource="truncated",o?.isConnected&&e&&o.sessionsPatch(r.gatewaySessionKey,{label:e}).catch(e=>{this.logger.debug?.(`[bridge-core] sessionsPatch (title) failed: ${e instanceof Error?e.message:e}`)}),this.transport.sendSessionUpdate(i,{sessionUpdate:"title_update",title:e})}}}const d=buildResolutionPlan(a);if(d.refs.length>0)try{const e=await resolveResources(d,this.writeObs);a=applyResolutions(a,e)}catch(e){this.logger.warn?.(`[bridge-core] Resource pre-resolution failed: ${e instanceof Error?e.message:e}`)}let c;if(this.beeosFileResolver){const e=this.beeosFileResolver.buildResolutionPlan(a);if(e.refs.length>0)try{c=await this.beeosFileResolver.resolveMetadataForPrompt(e)}catch(e){this.logger.warn?.(`[bridge-core] BeeOS file resolution failed: ${e instanceof Error?e.message:e}`)}}const g=toGatewayPrompt(a,{beeosFileResolutionsByUri:c});if("error"in g)return void this.transport.sendAcpError(e.id,Errors.PromptConvertFailed,{message:g.error});const{message:p,attachments:h}=g,m=resolveStandaloneSlashCommandName(a,p)??resolveSlashCmd(a,p),u="string"==typeof m;if(m&&this.handleSlashCommand){const t=this;if(await(async()=>{try{return await t.handleSlashCommand(m)}catch(e){return t.logger.warn?.(`[bridge-core] /${m} handler failed: ${e instanceof Error?e.message:e}`),!1}})())return this.emitPromptAsUserSessionUpdates(r,a,resolveTurnId(r)),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:`Loading ${m}...`},timestamp:(new Date).toISOString()},{turnId:resolveTurnId(r)}),void this.transport.sendResult(e.id,{stopReason:"end_turn"})}const f="chat.send";let y=u?p:withUserMessagePrefix(p);const w=this.config.canvasEnabled&&!u&&!1!==r.canvasActive&&!r.channelType;if(w){const e=r.promptsSinceCanvasInjection??0;!r.canvasPromptInjected||e>=10?(y=`${CANVAS_TERM_MAPPING}\n\n${y}`,r.canvasPromptInjected=!0,r.promptsSinceCanvasInjection=0,this.logger.debug?.("[bridge-core] Canvas term mapping injected (re-inject cycle)")):r.promptsSinceCanvasInjection=e+1}if(w){const e=this.getCanvasState?.();if(e){const t=e.resolveCanvasForSession(r.gatewaySessionKey);y=`${buildActiveSurfacesContext(e,t)??"[Canvas] Canvas panel is available. Use render_ui for structured/visual content."}\n\n${y}`}const t=r.referencedSurfaceId;t&&(y=`${CANVAS_UPDATE_HINT}\n[IMPORTANT: The user selected canvas surface "${t}" for editing. You MUST call render_ui with id="${t}" to UPDATE this existing surface. Do NOT create a new surface with a different id.]\n${y}`,r.referencedSurfaceId=void 0)}if(!u){const t=parseMentionsParam(e.params?.mentions);if(t.length>0){r.activeMentions=t;y=`${buildMentionInstruction(t)}\n${y}`,this.logger.debug?.(`[bridge-core] delegation mention instruction injected for ${t.length} agent(s)`)}else r.activeMentions=void 0}u&&this.logger.info?.(`[bridge-core] slash command passthrough sessionId=${i} command=/${m}`),r.promptDone||void 0===r.pendingPromptId&&void 0===r.currentRunId||(this.logger.warn?.(`[bridge-core] handleSessionPrompt: force-ending stale run sid=${i} oldRunId=${r.currentRunId} oldPromptId=${r.pendingPromptId}`),(r.currentRunId||r.gatewayRequestId)&&o.agentCancel(r.gatewaySessionKey,{runId:r.currentRunId,requestId:r.gatewayRequestId}),this.endRun(r,{kind:"complete",stopReason:"cancelled"})),this.state.resetForNewRun(r,i,this.transport),r.pendingPromptId=e.id,r.gatewayMethod=f;const I=resolveTurnId(r);this.emitPromptAsUserSessionUpdates(r,a,I),this.transport.sendSessionUpdate(i,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:""},timestamp:(new Date).toISOString()},I?{turnId:I}:void 0);const S=extractCronJobId(p);S&&(r.cronJobId=S),this.schedulePromptTimeout(r);const b=h.length>0?h.map(e=>({type:e.type,content:e.content,...e.mimeType?{mimeType:e.mimeType}:{},...e.fileName?{fileName:e.fileName}:{}})):void 0,v=`req_${randomUUID().replace(/-/g,"")}`,A={sessionKey:r.gatewaySessionKey,message:y,deliver:!1,idempotencyKey:randomUUID()};b?.length&&(A.attachments=b);if(!o.sendFrame({type:"req",id:v,method:f,params:A}))return this.state.clearPromptTimeout(r),r.pendingPromptId=void 0,r.assistantMq?.close(),r.assistantMq=void 0,r.pendingUserTurnUpdates=void 0,void this.transport.sendAcpError(e.id,Errors.SendFailed,{message:"failed to send prompt to gateway"});r.gatewayRequestId=v,this.state.promptsByGatewayRequestId.set(v,r)}replayUserTurnAsChunks(e,t,s,r,n,i,o){const a=splitUserReplayBlocks(s),l=i||void 0!==o?{...i?{turnId:i}:{},...void 0!==o?{seq:o}:{}}:void 0;for(const s of a){let i;if("text"===s.type){if(!s.text)continue;i={type:"text",text:s.text}}else i="resource_link"===s.type?{type:"resource_link",uri:s.uri??"",...s.fileName?{name:s.fileName}:{},...s.mimeType?{mimeType:s.mimeType}:{}}:{type:s.type,...s.mimeType?{mimeType:s.mimeType}:{},...s.fileName?{fileName:s.fileName}:{}};e.sendSessionUpdate(t,{sessionUpdate:"user_message_chunk",content:i,...r?{metadata:r}:{},...n?{timestamp:n}:{}},l)}}async replayAssistantTurnAsStream(e,t,s,r){const n=e=>{if(r||void 0!==e)return{...r?{turnId:r}:{},...void 0!==e?{seq:e}:{}}},i=n(s._seq);if(!s.sourceMessages?.length){const r={sessionUpdate:"history_turn",role:s.role,content:s.content,reasoning:s.reasoning,toolCalls:s.toolCalls,...s.timestamp?{timestamp:s.timestamp}:{}};return s.metadata&&(r.metadata=s.metadata),void e.sendSessionUpdate(t,r,i)}for(const r of s.sourceMessages){const i=r.updateType??"agent_message_chunk",o=n(r._seq);switch(i){case"agent_message_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_message_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{},...r.metadata?{metadata:r.metadata}:{}},o);break;case"agent_thought_chunk":e.sendSessionUpdate(t,{sessionUpdate:"agent_thought_chunk",content:{type:"text",text:r.content},...r.timestamp?{timestamp:r.timestamp}:{}},o);break;case"tool_call":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),a=r.toolName??"tool",l=r.content??"";let d={};if(l)try{d=JSON.parse(l)}catch{}e.sendSessionUpdate(t,buildToolCallPayload({toolCallId:n,toolName:a,args:d,kind:i?.kind,status:i?.status??"in_progress",timestamp:r.timestamp}),o);break}case"tool_call_update":{const n=r.toolCallId??"",i=s.toolCalls?.find(e=>e.id===n),a=r.toolName??i?.name??"tool",l=r.content??i?.output??"";let d=await readEnrichmentCache(n).catch(()=>{});if(!d){const e=extractBeeosFileUrisFromOutput(l);e.length>0&&(d=e.map(e=>({uri:e,name:"file"})))}if(!d&&"beeos_upload_file"===a){const e=await readUploadToolResult(n).catch(()=>{});if(e){const t=buildToolResultSessionContentBlocks(a,e).filter(e=>"resource_link"===e.content?.type&&e.content.uri).map(e=>({uri:e.content.uri,name:e.content.name??"file",...e.content.mimeType?{mimeType:e.content.mimeType}:{}}));t.length>0&&(d=t)}}e.sendSessionUpdate(t,buildToolCallUpdatePayload({toolCallId:n,toolName:a,outputText:l,kind:i?.kind,status:i?.status??"completed",resourceLinks:d,timestamp:r.timestamp}),o);break}}}}emitPromptAsUserSessionUpdates(e,t,s){const r=(new Date).toISOString(),n=s?{turnId:s}:void 0;for(const i of t){const t=this.toUserSessionUpdateContent(i);if(!t)continue;const o={sessionUpdate:"user_message_chunk",content:t,timestamp:r};s&&void 0===o._turnId&&(o._turnId=s),this.transport.sendSessionUpdate(e.sessionId,o,n),e.pendingUserTurnUpdates?.push(o)}}toUserSessionUpdateContent(e){if("text"===e.type){if(!e.text)return;return{type:"text",text:e.text}}if("image"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.name;if(!e.data&&!e.uri)return;return{type:"image",...e.data?{data:e.data}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource_link"===e.type){if(!e.uri)return;return{type:"resource_link",uri:e.uri,...e.title?{title:e.title}:{},...e.name?{name:e.name}:{},...e.mimeType??e.mime_type?{mimeType:e.mimeType??e.mime_type}:{}}}if("file"===e.type){const t=e.mimeType??e.mime_type??"application/octet-stream",s=e.fileName??e.file_name??e.filename??e.name;if(!e.data&&!e.text&&!e.uri)return;return{type:"file",...e.data?{data:e.data}:{},...e.text?{text:e.text}:{},...e.uri?{uri:e.uri}:{},...t?{mimeType:t}:{},...s?{fileName:s}:{}}}if("resource"===e.type){const t=e.resource??{},s=t.uri??e.uri,r=t.mimeType??t.mime_type??e.mimeType??e.mime_type,n=t.text??e.text,i=t.data??e.data,o=t.fileName??t.file_name??t.filename??t.name??e.fileName??e.file_name??e.filename??e.name;if(!s&&!n&&!i)return;return{type:"resource",resource:{...s?{uri:s}:{},...r?{mimeType:r}:{},...n?{text:n}:{},...i?{data:i}:{},...o?{fileName:o}:{}}}}}handleSessionCancel(e,t){const s=t&&"object"==typeof t&&"sessionId"in t?t.sessionId:void 0;if(!s)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.MissingParam,{param:"sessionId"}));const r=this.state.sessions.get(s);if(!r)return void(void 0!==e&&this.transport.sendAcpError(e,Errors.SessionNotFound));const n=this.getGateway(),i=n?.isConnected,o=!(!r.currentRunId&&!r.gatewayRequestId);if(i&&o){n.agentCancel(r.gatewaySessionKey,{runId:r.currentRunId,requestId:r.gatewayRequestId})}this.endRun(r,{kind:"complete",stopReason:"cancelled"})||this.transport.sendSessionUpdate(r.sessionId,{sessionUpdate:"stream_complete",stopReason:"cancelled"},{turnId:resolveTurnId(r)}),void 0!==e&&this.transport.sendResult(e,{})}endRun(e,t){if(e.promptDone)return this.logger.debug?.(`[bridge-core] endRun skipped (promptDone=true) sessionId=${e.sessionId} outcome=${t.kind}`),!1;if(void 0===e.pendingPromptId&&void 0===e.currentRunId)return this.logger.debug?.(`[bridge-core] endRun skipped (no pendingPromptId and no currentRunId) sessionId=${e.sessionId} outcome=${t.kind}`),!1;const s=resolveTurnId(e),r=s?{turnId:s}:void 0;e.promptDone=!0;const n=e.currentRunId;if("complete"===t.kind){if(n&&this.onRunComplete){const s=e.agentAssistantTextSoFar??e.chatAssistantTextSoFar??"";this.onRunComplete(n,s,t.stopReason)}void 0!==e.pendingPromptId&&this.transport.sendResult(e.pendingPromptId,{stopReason:t.stopReason}),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:t.stopReason},r),this.tryGenerateTitleFromHistory(e,e.sessionId)}else n&&this.onRunFailed&&this.onRunFailed(n,t.message),void 0!==e.pendingPromptId&&this.transport.sendError(e.pendingPromptId,t.code,t.message),this.transport.sendSessionUpdate(e.sessionId,{sessionUpdate:"stream_complete",stopReason:"error",error:t.message},r);return this.cleanupPrompt(e),this.onRunEnded?.(e,t),!0}schedulePromptTimeout(e){let t;if(this.state.clearPromptTimeout(e),e.cronJobId){const s=this.cronOverrides.get(e.cronJobId)??this.config.cronMaxDurationDefaultMs;t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,s))}else t=this.config.promptTimeoutMs??DEFAULT_PROMPT_TIMEOUT_MS;t<=0||(e.promptTimeoutTimer=setTimeout(()=>{void 0===e.pendingPromptId&&void 0===e.currentRunId||((e.currentRunId||e.gatewayRequestId)&&this.getGateway()?.agentCancel(e.gatewaySessionKey,{runId:e.currentRunId,requestId:e.gatewayRequestId}),this.logger.warn?.(`[bridge-core] Prompt timed out sessionId=${e.sessionId} after ${t}ms`),this.failPrompt(e,Errors.PromptTimeout.rpcCode,`Prompt timed out after ${t}ms`))},t),"object"==typeof e.promptTimeoutTimer&&"unref"in e.promptTimeoutTimer&&e.promptTimeoutTimer.unref())}cleanupPrompt(e){this.state.clearPromptTimeout(e),this.state.clearStreamIdleTimer(e);const t=e.currentRunId;if(t){this.state.promptsByRunId.delete(t),this.state.runIdToCronJobId.delete(t),this.state.markRunCompleted(t);const e=`tc_${t}_`;for(const[t,s]of this.state.toolCallIdMap)s.startsWith(e)&&this.state.toolCallIdMap.delete(t)}e.gatewayRequestId&&this.state.promptsByGatewayRequestId.delete(e.gatewayRequestId),e.pendingPromptId=void 0,e.promptDone=void 0,e.currentRunId=void 0,e.gatewayRequestId=void 0,e.assistantStreamStartedAt=void 0,e.gatewayMethod=void 0,e.assistantMq?.close(),e.assistantMq=void 0,e.pendingUserTurnUpdates=void 0}completePrompt(e,t){this.endRun(e,{kind:"complete",stopReason:t})}failPrompt(e,t,s){this.endRun(e,{kind:"error",code:t,message:s})}cleanupAllPendingPrompts(){for(const e of this.state.sessions.values())this.failPrompt(e,Errors.GatewayDisconnected.rpcCode,Errors.GatewayDisconnected.message)}handleGatewayResponse(e){const t=e.id;if(!t)return;const s=this.state.promptsByGatewayRequestId.get(t);if(!s||s.promptDone)return;if(!e.ok){const r=e.error,n=r?.message??"gateway returned error";return this.logger.warn?.(`[bridge-core] handleGatewayResponse: gateway rejected prompt gatewayRequestId=${t} error=${n}`),void this.failPrompt(s,Errors.SendFailed.rpcCode,n)}const r=e.payload,n=r?.runId;n?(s.currentRunId=n,s.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(n,s),this.logger.debug?.(`[bridge-core] handleGatewayResponse: bound runId=${n} gatewayRequestId=${t} sessionId=${s.sessionId}`)):this.logger.debug?.(`[bridge-core] handleGatewayResponse: no runId in payload gatewayRequestId=${t} sessionId=${s.sessionId}`)}maybeCompletePromptFromChatState(e,t){if(e.promptDone||"chat.send"!==e.gatewayMethod)return;const s="string"==typeof t.state?t.state.toLowerCase():void 0;if(s)if("final"===s){const s="string"==typeof t.stopReason?t.stopReason:void 0;this.completePrompt(e,s||"end_turn")}else if("aborted"===s||"cancelled"===s||"cancel"===s)this.completePrompt(e,"cancelled");else if("error"===s){const s=("string"==typeof t.summary?t.summary:void 0)??("string"==typeof t.message?t.message:void 0)??"gateway chat error";this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}async handleCronRequest(e){const t=this.getGateway();if(!t?.isConnected)return void this.transport.sendAcpError(e.id,Errors.GatewayNotConnected);const s=e.method.replace("/","."),r={...e.params??{}},n="cron.update"===s;!n||r.patch&&"object"==typeof r.patch||(r.patch={});const i=n?r.patch:r,o=i._beeos_meta;let a;if(delete i._beeos_meta,"cron.add"!==s&&"cron.update"!==s||!0!==i._beeos_internal)void 0!==i._beeos_internal&&delete i._beeos_internal;else if(delete i._beeos_internal,i.delivery={mode:"none"},"cron.add"===s){const e=await this.allocateCronChannel(i.name??"cron");e&&(i.description=encodeBeeosChannelTag(i.description,e))}else{const e=r.id,t="description"in i,s=i.description;let n,o=decodeBeeosChannelId(s);!o&&e&&(o=this.cronChannelCache.get(e)),!o&&e&&(n=await this.lookupCronJobDescription(e),o=decodeBeeosChannelId(n));let l=!1;o||(o=await this.allocateCronChannel(i.name??"cron"),l=!0),o&&(t?i.description=encodeBeeosChannelTag(s,o):l&&(i.description=encodeBeeosChannelTag(n,o)),a=o)}try{const n=await t.request(s,r),l="cron.list"===s||"cron.status"===s?this.enrichCronResponseWithOverrides(n):n;if(this.transport.sendResult(e.id,l??{}),("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id;if(e&&void 0!==o?.maxDurationMs){const t=Math.max(CRON_DURATION_FLOOR_MS,Math.min(this.config.cronMaxDurationCapMs,o.maxDurationMs));this.cronOverrides.set(e,t)}}if("cron.remove"===s){const t=e.params?.id;t&&(this.cronOverrides.delete(t),this.cronChannelCache.delete(t))}if(("cron.add"===s||"cron.update"===s)&&n&&"object"==typeof n){const e=n.id,t=decodeBeeosChannelId(i.description)??a;e&&t&&this.setCronChannelCache(e,t)}"cron.add"!==s&&"cron.update"!==s&&"cron.remove"!==s||this.refreshCronIsolatedSessionKeys()}catch(t){const r=t instanceof Error?t.message:"cron request failed";this.logger.warn?.(`[bridge-core] ${s} failed: ${r}`),this.transport.sendAcpError(e.id,Errors.SendFailed,{message:r})}}enrichCronResponseWithOverrides(e){if(!e||"object"!=typeof e)return e;const t=e,s=e=>{if(!e||"object"!=typeof e)return e;const t=e,s=t.id;if("string"!=typeof s)return e;const r=this.cronOverrides.get(s);return"number"!=typeof r?e:{...t,_beeos_meta:{maxDurationMs:r}}};if(Array.isArray(t.jobs))return{...t,jobs:t.jobs.map(s)};if(t.jobs&&"object"==typeof t.jobs){const e=t.jobs,r={};for(const[t,n]of Object.entries(e))r[t]=s(n);return{...t,jobs:r}}return e}async allocateCronChannel(e){if(this.agentFetch&&this.agentGatewayUrl)try{const t=`${this.agentGatewayUrl}/api/v1/channels`,s=await this.agentFetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({participants:[this.instanceId,"service:cron"],metadata:{source:"cron",job_name:e}})});if(!s.ok)return void this.logger.warn?.(`[bridge-core] channel alloc failed: ${s.status}`);return(await s.json()).id}catch(e){return void this.logger.warn?.(`[bridge-core] channel alloc error: ${e instanceof Error?e.message:e}`)}else this.logger.debug?.("[bridge-core] agentFetch/agentGatewayUrl unavailable, skip channel alloc")}async lookupCronJobDescription(e){const t=this.getGateway();if(t?.isConnected)try{const s=await t.request("cron.list",{}),r=s?.jobs;if(!Array.isArray(r))return;for(const t of r){if(!t||"object"!=typeof t)continue;const s=t;if(s.id===e)return"string"==typeof s.description?s.description:void 0}}catch(e){this.logger.debug?.(`[bridge-core] cron.list description lookup failed: ${e instanceof Error?e.message:e}`)}}setCronChannelCache(e,t){if(this.cronChannelCache.size>=AcpGatewayBridgeCore.CRON_CHANNEL_CACHE_MAX){const e=this.cronChannelCache.keys().next().value;void 0!==e&&this.cronChannelCache.delete(e)}this.cronChannelCache.set(e,t)}async refreshCronIsolatedSessionKeys(){const e=this.getGateway();if(e?.isConnected)try{const t=await e.request("cron.list",{}),s=t?.jobs;if(!Array.isArray(s))return;const r=new Set,n=[];for(const e of s){if(!e||"object"!=typeof e)continue;const t=e;if("string"==typeof t.id&&n.push(t.id),"isolated"!==t.sessionTarget)continue;const s=t.sessionKey;"string"==typeof s&&s.length>0&&r.add(s)}this.cronIsolatedSessionKeys=r,this.cronOverrides.reconcile(n)}catch(e){this.logger.debug?.(`[bridge-core] refreshCronIsolatedSessionKeys failed: ${e instanceof Error?e.message:e}`)}}}async function fetchWithRetry(e,t={}){const s=t.maxRetries??3;let r;for(let n=0;n<=s;n++){const i=await fetch(e,{signal:AbortSignal.timeout(12e4),redirect:"follow"});if(i.ok)return Buffer.from(await i.arrayBuffer());if(429===i.status||i.status>=500){const o=Number(i.headers.get("retry-after"))||0,a=1e3*Math.max(o,2**n);if(r=new Error(`HTTP ${i.status} from ${e}`),n<s){t.logger?.info?.(`[bridge-core] Download ${i.status}, retry ${n+1}/${s} in ${(a/1e3).toFixed(0)}s`),await new Promise(e=>setTimeout(e,a));continue}}throw new Error(`Failed to download zip: ${i.status}`)}throw r??new Error("Download failed after retries")}const SKILL_ROOT_MARKERS=["SKILL.md","skill.md","SKILL.MD"];async function resolveSkillRoot(e){for(const t of SKILL_ROOT_MARKERS)try{return await stat(join(e,t)),e}catch{}const t=(await readdir(e,{withFileTypes:!0})).filter(e=>e.isDirectory()&&!e.name.startsWith(".")&&"__MACOSX"!==e.name);if(1===t.length){const s=join(e,t[0].name);for(const e of SKILL_ROOT_MARKERS)try{return await stat(join(s,e)),s}catch{}return s}return e}async function extractZipToDir(e,t){const s=new AdmZip(e),r=join(t,".beeos-extract-tmp-"+Date.now());try{await mkdir(r,{recursive:!0}),s.extractAllTo(r,!0);const e=await resolveSkillRoot(r),n=await readdir(e,{withFileTypes:!0});let i=0;for(const s of n){if(s.name.startsWith(".beeos-extract-tmp")||"__MACOSX"===s.name)continue;const r=join(e,s.name),n=join(t,s.name);await rename(r,n).catch(async()=>{await cp(r,n,{recursive:!0})}),i++}if(0===i)throw new Error("Zip archive contained no extractable files")}finally{await rm(r,{recursive:!0,force:!0}).catch(()=>{})}}const VALID_SLUG_PATTERN=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,NON_ASCII_PATTERN=/[^\x00-\x7F]/;function validateSkillSlug(e){const t=e.trim();if(!t||t.includes("/")||t.includes("\\")||t.includes(".."))throw new Error(`Invalid skill slug: ${e}`);if(NON_ASCII_PATTERN.test(t)||!VALID_SLUG_PATTERN.test(t))throw new Error(`Invalid skill slug: ${e}`);return t}function safeDirName(e){const t=e.trim();return t?t.replaceAll("/","__").replaceAll("\\","__"):t}function resolveSkillInstallDir(e,t){const s=safeDirName(t),r=join(e,s),n=resolve(e),i=resolve(r),o=relative(n,i);if(!o||".."===o||o.startsWith(`..${sep}`)||path.isAbsolute(o))throw new Error("Invalid skill target path");return r}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{sv}from"../canvas/a2ui-helpers.js";const HANDLER_ID="beeos-file-artifact-presenter";export async function presentFileArtifacts(e,t,n,a){const s=n.filter(n=>shouldPresent(e,t,n));if(0===s.length)return;const i=s.filter(isImageArtifact),o=s.filter(e=>!isImageArtifact(e)),r="chat"===e.fileArtifactDisplayMode,c=a.getCanvasState?.()??null,l=canUseCanvas(e,a.config,c)?c.resolveCanvasForSession(e.gatewaySessionKey):void 0;if(!r&&c&&l&&i.length>0)try{presentImagesOnCanvas(c,l,t,i);for(const n of i)markPresented(e,t,n);a.writeObs({component:"acp-gateway",domain:"file_artifact",name:"file_artifact.presented",severity:"info",payload:{mode:"canvas",toolCallId:t,count:i.length,canvasId:l}})}catch(e){a.logger.warn?.(`[file-artifact-presenter] Canvas presentation failed: ${e instanceof Error?e.message:String(e)}`),o.push(...i)}else o.push(...i);for(const n of o)shouldPresent(e,t,n)&&(a.sendUpdate({sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:n.uri,name:n.name??"file",...n.mimeType?{mimeType:n.mimeType}:{}},timestamp:(new Date).toISOString()}),markPresented(e,t,n));o.length>0&&a.writeObs({component:"acp-gateway",domain:"file_artifact",name:"file_artifact.presented",severity:"info",payload:{mode:"chat",toolCallId:t,count:o.length}})}export function prefersChatFileArtifacts(e){const t=e.toLowerCase();return/聊天框|聊天消息|消息里|对话里|回复里|不要.{0,8}(canvas|画布)|不用.{0,8}(canvas|画布)|别.{0,8}(canvas|画布)/u.test(t)||/\b(chat message|in chat|show it in chat|not canvas|don't use canvas|do not use canvas)\b/.test(t)}function shouldPresent(e,t,n){return Boolean(n.uri&&!e.presentedFileArtifactKeys?.has(presentationKey(t,n.uri)))}function markPresented(e,t,n){e.presentedFileArtifactKeys??=new Set,e.presentedFileArtifactKeys.add(presentationKey(t,n.uri))}function presentationKey(e,t){return`${e}:${t}`}function canUseCanvas(e,t,n){return Boolean(t.canvasEnabled&&n&&!1!==e.canvasActive&&!e.channelType)}function presentImagesOnCanvas(e,t,n,a){const s=`artifact-${sanitizeId(n)}`,{components:i,rootId:o}=buildImageSurfaceComponents(a);e.has(s,t)?e.update(s,i,t,o):e.create(t,s,"panel",i,{rootComponentId:o,origin:{kind:"system",handlerId:HANDLER_ID}})}function buildImageSurfaceComponents(e){const t=1===e.length?e[0]?.name??"Device Screenshot":"File Artifacts",n=["title"],a=[{id:"title",component:{Text:{text:sv(t),usageHint:"h3"}}}];return e.forEach((e,t)=>{const s=`image_${t}`;n.push(s),a.push({id:s,component:{Image:{url:sv(e.uri),usageHint:"largeFeature",fit:"contain"}}})}),a.push({id:"root",component:{Column:{children:{explicitList:n}}}}),{components:a,rootId:"root"}}function sanitizeId(e){return e.replace(/[^a-zA-Z0-9_-]/g,"-").replace(/-+/g,"-").slice(0,80)||"file"}function isImageArtifact(e){const t=e.mimeType?.toLowerCase();if(t?.startsWith("image/"))return!0;const n=e.name?.toLowerCase();return Boolean(n&&/\.(avif|bmp|gif|jpe?g|png|svg|webp)$/.test(n))}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{CanvasStateManager}from"../canvas/state.js";import{prefersChatFileArtifacts,presentFileArtifacts}from"./file-artifact-presenter.js";function updateContent(e){return e&&"content"in e?e.content:void 0}function makeSession(e={}){return{sessionId:"s1",gatewaySessionKey:"main/s1",agentId:"main",createdAt:Date.now(),chatReplayDedupKeys:new Set,...e}}function makeDeps(e){const t=[];return{sent:t,deps:{config:{canvasEnabled:!0},logger:{warn:vi.fn(),debug:vi.fn(),info:vi.fn(),error:vi.fn()},writeObs:vi.fn(),getCanvasState:()=>e??null,sendUpdate:e=>t.push(e)}}}describe("presentFileArtifacts",()=>{it("creates an A2UI Image surface when Canvas is active and the artifact is an image",async()=>{const e=makeSession(),t=new CanvasStateManager;t.registerSession(e.gatewaySessionKey,"canvas-1");const{sent:a,deps:s}=makeDeps(t);await presentFileArtifacts(e,"tc-1",[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}],s),expect(a).toHaveLength(0);const n=t.get("artifact-tc-1");expect(n?.presentation).toBe("panel");const i=n?.components,c=i.find(e=>"image_0"===e.id)?.component.Image;expect(c.url?.literalString).toBe("beeos-file://file-1"),expect(c.usageHint).toBe("largeFeature"),expect(c.fit).toBe("contain")}),it("sends a chat resource_link when Canvas is inactive",async()=>{const e=makeSession({canvasActive:!1}),t=new CanvasStateManager;t.registerSession(e.gatewaySessionKey,"canvas-1");const{sent:a,deps:s}=makeDeps(t);await presentFileArtifacts(e,"tc-1",[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}],s),expect(a).toEqual([expect.objectContaining({sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}})])}),it("sends a chat resource_link when the user explicitly asked for chat",async()=>{const e=makeSession({fileArtifactDisplayMode:"chat"}),t=new CanvasStateManager;t.registerSession(e.gatewaySessionKey,"canvas-1");const{sent:a,deps:s}=makeDeps(t);await presentFileArtifacts(e,"tc-1",[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}],s),expect(updateContent(a[0])).toMatchObject({type:"resource_link",uri:"beeos-file://file-1"}),expect(t.has("artifact-tc-1","canvas-1")).toBe(!1)}),it("falls back to chat when Canvas presentation fails",async()=>{const e=makeSession(),t={resolveCanvasForSession:()=>"canvas-1",has:()=>!1,create:()=>{throw new Error("relay write failed")}},{sent:a,deps:s}=makeDeps(t);await presentFileArtifacts(e,"tc-1",[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}],s),expect(updateContent(a[0])).toMatchObject({type:"resource_link",uri:"beeos-file://file-1"})}),it("keeps non-image files in chat attachments",async()=>{const e=makeSession(),t=new CanvasStateManager;t.registerSession(e.gatewaySessionKey,"canvas-1");const{sent:a,deps:s}=makeDeps(t);await presentFileArtifacts(e,"tc-1",[{uri:"beeos-file://file-1",name:"report.pdf",mimeType:"application/pdf"}],s),expect(updateContent(a[0])).toMatchObject({type:"resource_link",uri:"beeos-file://file-1",name:"report.pdf"}),expect(t.sizeOf("canvas-1")).toBe(0)}),it("does not present the same toolCallId:uri twice",async()=>{const e=makeSession({canvasActive:!1}),{sent:t,deps:a}=makeDeps(null),s={uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"};await presentFileArtifacts(e,"tc-1",[s],a),await presentFileArtifacts(e,"tc-1",[s],a),expect(t).toHaveLength(1)})}),describe("prefersChatFileArtifacts",()=>{it("detects explicit chat-only file display requests",()=>{expect(prefersChatFileArtifacts("不要推送到右侧 Canvas,我想在聊天框消息里展示图片")).toBe(!0),expect(prefersChatFileArtifacts("please show it in chat, not canvas")).toBe(!0),expect(prefersChatFileArtifacts("截图看看")).toBe(!1)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it,vi}from"vitest";import{AcpGatewayEvents}from"./gateway-events.js";import{AcpGatewaySessionState}from"./session-state.js";function makeHarness(){const e={info:vi.fn(),warn:vi.fn(),debug:vi.fn(),error:vi.fn()},t=new AcpGatewaySessionState(e),s=vi.fn().mockReturnValue(!0),n={sendSessionUpdate:s,sendResult:vi.fn(),sendAcpError:vi.fn(),sendNotification:vi.fn(),resetSessionUpdateCount:vi.fn(),setSessionUpdateObserver:vi.fn(),isReady:()=>!0,get forwardControl(){return{forwardThinking:!0,forwardToolCalls:!0}}},a=new AcpGatewayEvents({state:t,transport:n,config:{gateway:{agentId:"main"},canvasEnabled:!0},logger:e,writeObs:vi.fn(),completePrompt:vi.fn(),failPrompt:vi.fn(),schedulePromptTimeout:vi.fn(),maybeCompletePromptFromChatState:vi.fn()}),o={sessionId:"s1",gatewaySessionKey:"main/s1",agentId:"main",currentRunId:"run-1",pendingPromptId:1,promptDone:!1,createdAt:Date.now(),chatReplayDedupKeys:new Set};return t.sessions.set(o.sessionId,o),t.promptsByRunId.set("run-1",o),{events:a,sendSessionUpdate:s}}function sentUpdates(e){return e.mock.calls.map(e=>e[1])}describe("gateway-events file artifact presentation",()=>{it("presents structured files from chat.toolResult content wrappers as chat resource links",async()=>{const e=makeHarness(),t={runId:"run-1",sessionKey:"main/s1",seq:1,state:"delta",message:{role:"assistant",timestamp:Date.now(),content:[{type:"toolResult",id:"tc-1",name:"beeos_get_task",result:{content:JSON.stringify({result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})}}]}};e.events.handleChatEvent(t),await Promise.resolve();const s=sentUpdates(e.sendSessionUpdate);expect(s.some(e=>"tool_call_update"===e.sessionUpdate)).toBe(!0),expect(s).toContainEqual(expect.objectContaining({sessionUpdate:"agent_message_chunk",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}))})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if(!isRecord(t)||!Array.isArray(t.files))return;const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;schedulePromptTimeout;maybeCompletePromptFromChatState;agentFetchFn;getMessageClient;getMsgPrincipalId;gatewayRequest;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"timeout"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,n)&&e.assistantMq?.push(n);continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:r});this.sendTurnUpdate(e,i)&&e.assistantMq?.push(i);continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:n,resourceLinks:extractResourceLinksFromRawResult(n),timestamp:r});this.sendTurnUpdate(e,l)&&e.assistantMq?.push(l);continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(i))l=s.slice(i.length);else{if(i.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${i.length}`);const d={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,d),e.assistantMq?.push(d)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,r=classifyHeartbeatFilter(o);r.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:r.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);i&&n(buildToolCallUpdatePayload({toolCallId:o,toolName:a,outputText:i,rawOutput:r,resourceLinks:extractResourceLinksFromRawResult(r),timestamp:s}));continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",r=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",r={...o,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);n(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n){const t="string"==typeof s?.stopReason?s.stopReason:void 0;this.completePrompt(e,t||"end_turn")}else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,d=a??r,c=n?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:d,toolName:i,rawText:l,rawResult:c})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const h=[],g=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?g.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&h.push(t.content.text));const p=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:h.join("\n"),rawOutput:c,resourceLinks:g,timestamp:s});this.sendTurnUpdate(t,p)&&t.assistantMq?.push(p)}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(n&&(e.sessionId=n),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),n=s?.jobs;if(Array.isArray(n))for(const s of n)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const r="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!r)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const i=this.state.findSessionByKey(r),l=i?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${r} job=${e}`);const d=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:d}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const r=randomUUID();try{await e.messages.send({conversationId:a,id:r,type:"agent_reply",content:{text:o,cron_job_id:n,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewFilteredText(t.cumulative),s=void 0!==t.delta?previewFilteredText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
|
|
1
|
+
import{createHash,randomUUID}from"node:crypto";import{Errors}from"../errors/index.js";import{BEEOS_CHANNEL_TAG_RE,decodeBeeosChannelId,encodeBeeosChannelTag}from"./bridge-core.js";import{formatFriendlyError}from"../errors/classify.js";import{stripTransportMetadata,normalizeTransportText,classifyHeartbeatFilter,previewFilteredText}from"../message-filter.js";import{resolveToolResultBlocks}from"./tool-result-payload-strategies.js";import{presentFileArtifacts}from"./file-artifact-presenter.js";import{buildToolCallPayload,buildToolCallUpdatePayload}from"./tool-call-payload-builder.js";import{DEFAULT_STREAM_IDLE_TIMEOUT_MS,DEFAULT_STREAM_STALE_TIMEOUT_MS}from"./types.js";import{MessageQueue}from"../utils/mq.js";function asString(t){if("string"==typeof t){return t.trim()||void 0}}function isRecord(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function normalizeText(t){if("string"==typeof t)return normalizeTransportText(t.replace(/\r\n/g,"\n").replace(/\r/g,"\n"))}function extractResourceLinksFromRawResult(t){if("string"==typeof t){const e=parseJsonObject(t);return e?extractResourceLinksFromRawResult(e):void 0}if(!isRecord(t))return;if(!Array.isArray(t.files)){for(const e of["content","output","result","details"]){const s=t[e];if(s===t)continue;const n=extractResourceLinksFromRawResult(s);if(n?.length)return n}return}const e=[];for(const s of t.files){if(!isRecord(s))continue;const t=asString(s.uri)??(asString(s.fileId)?`beeos-file://${s.fileId}`:void 0);t&&e.push({uri:t,name:asString(s.name)??"file",...asString(s.mimeType)?{mimeType:asString(s.mimeType)}:{}})}return e.length>0?e:void 0}function parseJsonObject(t){const e=t.trim();if(e.startsWith("{"))try{const t=JSON.parse(e);return isRecord(t)?t:void 0}catch{return}}const DEDUP_HASH_THRESHOLD=256;function dedupKey(t,...e){const s=`${t}:${e.join(":")}`;if(s.length<=256)return s;return`${t}:h:${createHash("sha256").update(s).digest("hex").slice(0,24)}`}function toISO(t){if(!(null==t||t<=0))try{return new Date(t).toISOString()}catch{return}}function resolveToolArgs(t){const e=t.arguments??t.args??t.input;if(isRecord(e))return e;if("string"==typeof e){const t=e.trim();if(t.startsWith("{")||t.startsWith("["))try{return JSON.parse(t)}catch{}}return{}}export function resolveTurnId(t){if(t.currentRunId)return t.currentRunId;const e=t.pendingPromptId;return void 0!==e?"string"==typeof e?`req:${e}`:`req:${String(e)}`:void 0}export function roleFromSessionUpdate(t){const e=t.sessionUpdate;if(e)return"user_message_chunk"===e?"user":"system_message_chunk"===e?"system":e.startsWith("agent_")||e.startsWith("tool_")?"assistant":"history_turn"===e?"user"===t.role?"user":"system"===t.role?"system":"assistant":void 0}export function recordTurnIdForRole(t,e,s){if(!s)return;const n=roleFromSessionUpdate(e);n&&(t.lastTurnIdByRole||(t.lastTurnIdByRole={}),t.lastTurnIdByRole[n]=s)}export class AcpGatewayEvents{state;transport;config;logger;writeObs;completePrompt;failPrompt;schedulePromptTimeout;maybeCompletePromptFromChatState;agentFetchFn;getMessageClient;getMsgPrincipalId;gatewayRequest;getCanvasState;cronChannelCache;constructor(t){this.state=t.state,this.transport=t.transport,this.config=t.config,this.logger=t.logger,this.writeObs=t.writeObs,this.completePrompt=t.completePrompt,this.failPrompt=t.failPrompt,this.schedulePromptTimeout=t.schedulePromptTimeout,this.maybeCompletePromptFromChatState=t.maybeCompletePromptFromChatState,this.agentFetchFn=t.agentFetch,this.getMessageClient=t.getMessageClient,this.getMsgPrincipalId=t.getMsgPrincipalId,this.gatewayRequest=t.gatewayRequest,this.getCanvasState=t.getCanvasState,this.cronChannelCache=t.cronChannelCache}touchStreamActivity(t){t.lastStreamActivityTs=Date.now(),this.resetStreamIdleTimer(t)}resetStreamIdleTimer(t){this.state.clearStreamIdleTimer(t);const e=this.config.streamIdleTimeoutMs??DEFAULT_STREAM_IDLE_TIMEOUT_MS,s=this.config.streamStaleTimeoutMs??DEFAULT_STREAM_STALE_TIMEOUT_MS;t.streamIdleTimer=setTimeout(()=>{if(!t.lastStreamActivityTs)return;const e=Date.now()-t.lastStreamActivityTs;e>s&&(this.logger.warn?.(`[gateway-events] Stream stale timeout sessionId=${t.sessionId} stale=${e}ms`),this.completePrompt(t,"timeout"))},e),"object"==typeof t.streamIdleTimer&&"unref"in t.streamIdleTimer&&t.streamIdleTimer.unref()}sendTurnUpdate(t,e){const s=resolveTurnId(t);return s&&void 0===e._turnId&&(e._turnId=s),recordTurnIdForRole(t,e,s),!!t.sessionLoadInProgress||this.transport.sendSessionUpdate(t.sessionId,e,{turnId:s})}sendToolCallSpacer(t,e){const s={sessionUpdate:"agent_message_chunk",content:{type:"text",text:"\n"},...e?{timestamp:e}:{}};this.sendTurnUpdate(t,s),t.assistantMq?.push(s)}resolveToolCallId(t,e,s){const n=asString(e.toolCallId)??asString(e.tool_call_id)??asString(e.callId)??asString(e.id);if(n){const e=this.state.toolCallIdMap.get(n);return e?(t.sessionId&&this.state.toolCallToSessionMap.set(e,t.sessionId),e):(this.state.toolCallIdMap.set(n,n),t.sessionId&&this.state.toolCallToSessionMap.set(n,t.sessionId),n)}const o=`tc_${t.currentRunId??"unknown"}_${this.state.nextToolCallSeq++}`;return t.sessionId&&this.state.toolCallToSessionMap.set(o,t.sessionId),o}handleChatEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??this.state.findSessionByKey(t.sessionKey);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}e.sessionId;const s=t.message;if(s){this.touchStreamActivity(e);if("assistant"===s.role){if(!Array.isArray(s.content))return;for(const n of s.content){const o=n;if(!isRecord(o))continue;const a=asString(o.type);if(!a)continue;const r=toISO(s.timestamp);if("thinking"===a){if(e.hasAgentThinkingStream)continue;const t=normalizeText(o.thinking)??normalizeText(o.text);if(!t)continue;const s=dedupKey("chat:thinking",t);if(e.chatReplayDedupKeys.has(s))continue;e.chatReplayDedupKeys.add(s);const n={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:t},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,n)&&e.assistantMq?.push(n);continue}if("toolCall"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolStartStream)continue;const t=this.resolveToolCallId(e,o,"start"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=resolveToolArgs(o),a=dedupKey("chat:tool_start",t,JSON.stringify(n));if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=buildToolCallPayload({toolCallId:t,toolName:s,args:n,timestamp:r});this.sendTurnUpdate(e,i)&&e.assistantMq?.push(i);continue}if("toolResult"===a){if(this.sendToolCallSpacer(e,r),e.hasAgentToolResultStream)continue;const t=this.resolveToolCallId(e,o,"result"),s=asString(o.name)??asString(o.toolName)??asString(o.tool_name)??"tool",n=o.result,a=normalizeText(o.text)??normalizeText(o.result)??(isRecord(n)||Array.isArray(n)?JSON.stringify(n,null,2):void 0);if(!a)continue;const i=dedupKey("chat:tool_result",t,a);if(e.chatReplayDedupKeys.has(i))continue;e.chatReplayDedupKeys.add(i);const l=extractResourceLinksFromRawResult(n),c=buildToolCallUpdatePayload({toolCallId:t,toolName:s,outputText:a,rawOutput:n,resourceLinks:l,timestamp:r});this.sendTurnUpdate(e,c)&&e.assistantMq?.push(c),this.presentToolResultFiles(e,t,l).catch(t=>{this.logger.warn?.(`[gateway-events] File artifact presentation failed: ${t instanceof Error?t.message:t}`)});continue}if("text"===a&&o.text){if(e.hasAgentAssistantStream||e.agentAssistantTextSoFar)continue;const s=stripTransportMetadata(o.text);if(!s)continue;const n=classifyHeartbeatFilter(s);if(n.filtered){this.logHeartbeatSkip({source:"chat-text",reason:n.reason,sessionId:e.sessionId,runId:t.runId,cumulative:s});continue}const a=dedupKey("chat:text",s);if(e.chatReplayDedupKeys.has(a))continue;e.chatReplayDedupKeys.add(a);const i=e.chatAssistantTextSoFar??"";let l;if(e.chatAssistantTextSoFar=s,s.startsWith(i))l=s.slice(i.length);else{if(i.startsWith(s))continue;l=s}if(!l)continue;this.logger.debug?.(`[gateway-events] chat.text delta seq=${t.seq} runId=${t.runId} len=${l.length} prevLen=${i.length}`);const c={sessionUpdate:"agent_message_chunk",content:{type:"text",text:l},...r?{timestamp:r}:{}};this.sendTurnUpdate(e,c),e.assistantMq?.push(c)}}}}else"delta"===t.state&&this.touchStreamActivity(e);if(this.maybeCompletePromptFromChatState(e,t),!e.promptDone)if("final"===t.state)this.completePrompt(e,t.stopReason||"end_turn");else if("aborted"===t.state||"cancelled"===t.state||"cancel"===t.state)this.completePrompt(e,"cancelled");else if("error"===t.state){const s=t.errorMessage||Errors.AgentError.message;this.failPrompt(e,Errors.AgentError.rpcCode,formatFriendlyError(s))}}handleAgentEvent(t){const e=(t.runId?this.state.promptsByRunId.get(t.runId):void 0)??(t.sessionKey?this.state.findSessionByKey(t.sessionKey):void 0);if(!e)return;if(!this.state.isCurrentStreamOwner(e,t.runId)){const s=!e.currentRunId&&void 0===e.pendingPromptId;if(!t.runId||!s||this.state.isRunCompleted(t.runId))return;this.adoptBackgroundRun(e,t.runId)}t.runId&&e.cronJobId&&!this.state.runIdToCronJobId.has(t.runId)&&this.state.setRunCronJobId(t.runId,e.cronJobId);e.sessionId;this.touchStreamActivity(e);const s=toISO(t.ts),n=t=>{this.sendTurnUpdate(e,t),e.assistantMq?.push(t)};if("assistant"===t.stream){e.hasAgentAssistantStream=!0;const o=this.state.extractAgentAssistantDelta(e,t.data??{});if(o){const a=normalizeTransportText(o);if(a){const o=e.agentAssistantTextSoFar??a,r=classifyHeartbeatFilter(o);r.filtered?this.logHeartbeatSkip({source:"assistant-delta",reason:r.reason,sessionId:e.sessionId,runId:t.runId,cumulative:o,delta:a}):(this.logger.debug?.(`[gateway-events] agent.assistant delta seq=${t.seq} runId=${t.runId} len=${a.length} source=${t.data?.delta?"delta":"cumulative"}`),n({sessionUpdate:"agent_message_chunk",content:{type:"text",text:a},...s?{timestamp:s}:{}}))}}const a=[],r=t.data;r&&(Array.isArray(r.content)?a.push(...r.content):isRecord(r.content)?a.push(r.content):"string"==typeof r.type&&a.push(r));for(const t of a){if(!isRecord(t))continue;const o=asString(t.type);if("thinking"===o){e.hasAgentThinkingStream=!0;const n=normalizeText(t.thinking)??normalizeText(t.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}continue}if("toolCall"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const o=this.resolveToolCallId(e,t,"start"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=resolveToolArgs(t);n(buildToolCallPayload({toolCallId:o,toolName:a,args:r,timestamp:s}));continue}if("toolResult"===o){this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0;const o=this.resolveToolCallId(e,t,"result"),a=asString(t.name)??asString(t.toolName)??asString(t.tool_name)??"tool",r=t.result,i=normalizeText(t.text)??normalizeText(t.result)??(isRecord(r)||Array.isArray(r)?JSON.stringify(r,null,2):void 0);if(i){const t=extractResourceLinksFromRawResult(r);n(buildToolCallUpdatePayload({toolCallId:o,toolName:a,outputText:i,rawOutput:r,resourceLinks:t,timestamp:s})),this.presentToolResultFiles(e,o,t).catch(t=>{this.logger.warn?.(`[gateway-events] File artifact presentation failed: ${t instanceof Error?t.message:t}`)})}continue}}}else if("thinking"===t.stream){e.hasAgentThinkingStream=!0;const n=normalizeText(t.data?.delta)??normalizeText(t.data?.text);if(n){const t={sessionUpdate:"agent_thought_chunk",content:{type:"text",text:n},...s?{timestamp:s}:{}};this.sendTurnUpdate(e,t)&&e.assistantMq?.push(t)}}else if("tool"===t.stream){const o=t.data,a="string"==typeof o?.phase?o.phase:void 0;if("start"===a){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const t=this.resolveToolCallId(e,o,"start"),a=asString(o?.name)??asString(o?.toolName)??asString(o?.tool_name)??"tool",r=resolveToolArgs(o);n(buildToolCallPayload({toolCallId:t,toolName:a,args:r,timestamp:s}))}else if("result"===a)this.sendToolCallSpacer(e,s),e.hasAgentToolResultStream=!0,this.handleToolResult(e,t,s).catch(t=>{this.logger.warn?.(`[gateway-events] Tool result handling failed: ${t instanceof Error?t.message:t}`)});else{const t=o?.toolCall;if(t){this.sendToolCallSpacer(e,s),e.hasAgentToolStartStream=!0;const a=asString(t?.name)??asString(o?.name)??asString(o?.toolName)??"tool",r={...o,...t},i=this.resolveToolCallId(e,r,"start"),l=resolveToolArgs(r);n(buildToolCallPayload({toolCallId:i,toolName:a,args:l,timestamp:s}))}}}else if("lifecycle"===t.stream){const s=t.data,n="string"==typeof s?.phase?s.phase:void 0;if("end"===n){const t="string"==typeof s?.stopReason?s.stopReason:void 0;this.completePrompt(e,t||"end_turn")}else if("cancelled"===n||"cancel"===n)this.completePrompt(e,"cancelled");else if("error"===n){const n=s?.error,o=n&&"object"==typeof n?n:void 0,a=("string"==typeof s?.message?s.message:void 0)??("string"==typeof n?n:void 0)??("string"==typeof o?.message?o.message:void 0)??("string"==typeof s?.reason?s.reason:void 0)??(o?JSON.stringify(o):void 0)??"gateway lifecycle error",r=formatFriendlyError(a);this.logger.warn?.(`[gateway-events] lifecycle error runId=${t.runId} sessionKey=${t.sessionKey} message=${a} data=${JSON.stringify(s)}`),this.failPrompt(e,Errors.AgentError.rpcCode,r)}}}async handleToolResult(t,e,s){const n=e.data,o=n?.toolCall,a=("string"==typeof o?.id?o.id:void 0)??("string"==typeof n?.toolCallId?n.toolCallId:void 0),r=(a?this.state.toolCallIdMap.get(a):void 0)??a??`tc_${e.runId}_${e.seq}`,i=("string"==typeof o?.name?o.name:void 0)??("string"==typeof n?.name?n.name:void 0)??"tool",l="string"==typeof n?.text?n.text:void 0,c=a??r,d=n?.result;let u;try{u=await resolveToolResultBlocks({toolCallId:c,toolName:i,rawText:l,rawResult:d})}catch{u=l?[{type:"content",content:{type:"text",text:l}}]:[]}0===u.length&&l&&(u=[{type:"content",content:{type:"text",text:l}}]);const h=[],g=[];for(const t of u)t.content&&("resource_link"===t.content.type&&t.content.uri?g.push({uri:t.content.uri,name:t.content.name??"file",...t.content.mimeType?{mimeType:t.content.mimeType}:{}}):t.content.text&&h.push(t.content.text));const p=buildToolCallUpdatePayload({toolCallId:r,toolName:i,outputText:h.join("\n"),rawOutput:d,resourceLinks:g,timestamp:s});this.sendTurnUpdate(t,p)&&t.assistantMq?.push(p),await this.presentToolResultFiles(t,r,g)}async presentToolResultFiles(t,e,s){s?.length&&await presentFileArtifacts(t,e,s,{config:this.config,logger:this.logger,writeObs:this.writeObs,getCanvasState:this.getCanvasState,sendUpdate:e=>{this.sendTurnUpdate(t,e)&&t.assistantMq?.push(e)}})}adoptBackgroundRun(t,e){this.logger.debug?.(`[gateway-events] Adopting background run ${e} on session ${t.sessionId} (prev=${t.currentRunId})`),t.currentRunId=e,t.promptDone=!1,this.state.promptsByRunId.set(e,t);const s=this.state.runIdToCronJobId.get(e);s&&(t.cronJobId=s),t.assistantStreamStartedAt=Date.now(),t.lastStreamActivityTs=Date.now(),t.hasAgentAssistantStream=void 0,t.hasAgentThinkingStream=void 0,t.hasAgentToolStartStream=void 0,t.hasAgentToolResultStream=void 0,t.chatAssistantTextSoFar=void 0,t.agentAssistantTextSoFar=void 0,t.chatReplayDedupKeys?.clear(),t.presentedFileArtifactKeys=new Set,t.assistantMq&&!t.assistantMq.closed||(t.assistantMq=new MessageQueue),this.schedulePromptTimeout(t)}handleCronEvent(t){this.transport.sendNotification("_beeos/cron_event",{payload:t});const e=t??{};"string"==typeof e.runId&&"string"==typeof e.jobId&&this.state.setRunCronJobId(e.runId,e.jobId),this.config.agentGatewayUrl&&this.agentFetchFn&&this.reportCronEventToAgentGateway(e),"added"===e.action&&this.maybeBackfillBeeosChannel(e),"finished"===e.action&&"string"==typeof e.summary&&e.summary&&this.publishCronResult(e)}async reportCronEventToAgentGateway(t){if(!this.config.agentGatewayUrl||!this.agentFetchFn)return;const e={...t},s="string"==typeof t.sessionKey?t.sessionKey:void 0;s&&null==e.sessionKey&&(e.sessionKey=s);const n=s?this.state.findSessionByKey(s)?.sessionId:void 0;if(n&&(e.sessionId=n),("string"!=typeof e.name||!e.name)&&"string"==typeof t.jobId&&t.jobId&&this.gatewayRequest)try{const s=await this.gatewayRequest("cron.list",{}),n=s?.jobs;if(Array.isArray(n))for(const s of n)if(isRecord(s)&&s.id===t.jobId&&"string"==typeof s.name&&s.name){e.name=s.name;break}}catch(t){this.logger.debug?.(`[gateway-events] cron.list lookup failed: ${t instanceof Error?t.message:t}`)}const o=`${this.config.agentGatewayUrl}/api/v1/agent/events`;try{await this.agentFetchFn(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"cron",data:e})})}catch(t){this.logger.warn?.(`[gateway-events] event report failed: ${t instanceof Error?t.message:t}`)}}async maybeBackfillBeeosChannel(t){const e="string"==typeof t.jobId?t.jobId:void 0;if(!e)return;if(!this.gatewayRequest)return;if(this.cronChannelCache?.get(e))return;let s;try{const t=await this.gatewayRequest("cron.list",{}),n=t?.jobs;if(Array.isArray(n))for(const t of n){if(!t||"object"!=typeof t)continue;const n=t;if(n.id===e){s=n;break}}}catch(t){return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel cron.list failed: ${t instanceof Error?t.message:t}`)}if(!s)return;const n="string"==typeof s.description?s.description:"";if(BEEOS_CHANNEL_TAG_RE.test(n)){const t=decodeBeeosChannelId(n);return void(t&&this.setCronChannelCacheLocal(e,t))}if("main"===("string"==typeof s.sessionTarget?s.sessionTarget:void 0))return;const o=isRecord(s.payload)?s.payload:void 0;if("systemEvent"===(o&&"string"==typeof o.kind?o.kind:void 0))return;const a=isRecord(s.delivery)?s.delivery:void 0;if(a&&"none"===a.mode)return;const r="string"==typeof s.sessionKey?s.sessionKey:void 0;if(!r)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: job has no sessionKey job=${e}`);const i=this.state.findSessionByKey(r),l=i?.channelId;if(!l)return void this.logger.debug?.(`[gateway-events] maybeBackfillBeeosChannel: no channelId on ACPSession for sessionKey=${r} job=${e}`);const c=encodeBeeosChannelTag(n,l);try{await this.gatewayRequest("cron.update",{id:e,patch:{description:c}}),this.setCronChannelCacheLocal(e,l),this.logger.info?.(`[gateway-events] backfilled beeos:channel for LLM-created cron job=${e} channel=${l}`)}catch(t){this.logger.warn?.(`[gateway-events] maybeBackfillBeeosChannel cron.update failed: ${t instanceof Error?t.message:t}`)}}setCronChannelCacheLocal(t,e){this.cronChannelCache&&this.cronChannelCache.set(t,e)}async publishCronResult(t){const e=this.getMessageClient?.();if(!e)return;const s=this.getMsgPrincipalId?.();if(!s)return;const n="string"==typeof t.jobId?t.jobId:void 0;if(!n)return;const o=t.summary;let a=this.cronChannelCache?.get(n);if(!a&&this.gatewayRequest)try{const t=await this.gatewayRequest("cron.list",{}),e=t?.jobs;if(Array.isArray(e))for(const t of e){if(!t||"object"!=typeof t)continue;const e=t;if(e.id===n){a=decodeBeeosChannelId(e.description);break}}}catch(t){this.logger.debug?.(`[gateway-events] cron.list fallback failed: ${t instanceof Error?t.message:t}`)}if(!a)return;const r=randomUUID();try{await e.messages.send({conversationId:a,id:r,type:"agent_reply",content:{text:o,cron_job_id:n,session_key:t.sessionKey??void 0}}),this.logger.info?.(`[gateway-events] published cron result to channel=${a} job=${n}`)}catch(t){const e=t instanceof Error?t.message:String(t);if(isChannelGoneError(t,e))return this.logger.info?.(`[gateway-events] origin channel gone (session likely deleted), skipping publish: channel=${a} job=${n} err=${e}`),void this.cronChannelCache?.delete(n);this.logger.warn?.(`[gateway-events] publishCronResult failed: ${e}`)}}logHeartbeatSkip(t){const e=previewFilteredText(t.cumulative),s=void 0!==t.delta?previewFilteredText(t.delta):void 0;this.logger.info?.(`[gateway-events] SKIP heartbeat ${t.source} reason=${t.reason} sessionId=${t.sessionId} runId=${t.runId??"n/a"} cumLen=${t.cumulative.length}`+(void 0!==t.delta?` deltaLen=${t.delta.length}`:"")+` cumulative="${e}"`+(void 0!==s?` delta="${s}"`:"")),this.writeObs({component:"gateway-events",domain:"lifecycle",name:"heartbeat_filter_skip",severity:"info",summary:`heartbeat ${t.source} drop (${t.reason})`,payload:{source:t.source,reason:t.reason,sessionId:t.sessionId,runId:t.runId,cumulativeLen:t.cumulative.length,cumulativePreview:e,...void 0!==t.delta?{deltaLen:t.delta.length,deltaPreview:s}:{}}})}}export function isChannelGoneError(t,e){const s=e.toLowerCase();if(s.includes("not found")||s.includes("no such channel")||s.includes("channel does not exist")||s.includes("channel gone")||s.includes("410")||/\b404\b/.test(s))return!0;if(isRecord(t)){const e=t.status??t.statusCode??t.code;if(404===e||410===e)return!0}return!1}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{MessageQueue}from"../utils/mq.js";import{isExternalInvocationSessionKey}from"./external-protocol-tags.js";const MAX_TOOL_CALL_MAP_ENTRIES=5e3,MAX_COMPLETED_RUN_IDS=200;export class AcpGatewaySessionState{sessions=new Map;promptsByRunId=new Map;promptsByGatewayRequestId=new Map;sessionsByGatewayKey=new Map;toolCallIdMap=new Map;toolCallToSessionMap=new Map;runIdToCronJobId=new Map;nextToolCallSeq=0;completedRunIds=new Set;lastActiveSessionId;sessionGcTimer=null;MAX_IDLE_SESSION_MS=864e5;EXTERNAL_SESSION_IDLE_MS=3e5;SESSION_GC_INTERVAL_MS=3e5;logger;constructor(s){this.logger=s}findSessionByKey(s){return this.sessionsByGatewayKey.get(s)}resolveSessionFromParams(s){const e="string"==typeof s?.sessionId?s.sessionId:void 0;if(e)return this.sessions.get(e)}isCurrentStreamOwner(s,e){return!e||!!s.currentRunId&&e===s.currentRunId}markRunCompleted(s){if(this.completedRunIds.add(s),this.completedRunIds.size>200){const s=this.completedRunIds.values().next().value;s&&this.completedRunIds.delete(s)}}isRunCompleted(s){return this.completedRunIds.has(s)}setRunCronJobId(s,e){if(this.runIdToCronJobId.set(s,e),this.runIdToCronJobId.size>400){const s=this.runIdToCronJobId.keys().next().value;void 0!==s&&this.runIdToCronJobId.delete(s)}}clearPromptTimeout(s){s.promptTimeoutTimer&&(clearTimeout(s.promptTimeoutTimer),s.promptTimeoutTimer=void 0)}clearStreamIdleTimer(s){s.streamIdleTimer&&(clearTimeout(s.streamIdleTimer),s.streamIdleTimer=void 0)}resetForNewRun(s,e,t){this.clearStreamIdleTimer(s),s.currentRunId&&(this.promptsByRunId.delete(s.currentRunId),this.runIdToCronJobId.delete(s.currentRunId)),s.gatewayRequestId&&this.promptsByGatewayRequestId.delete(s.gatewayRequestId),s.currentRunId=void 0,s.gatewayRequestId=void 0,s.assistantMq&&!s.assistantMq.closed&&(this.logger.warn?.(`[session-state] resetForNewRun discarding stale assistantMq sessionId=${e}`),s.assistantMq.close()),s.assistantMq=new MessageQueue,s.pendingUserTurnUpdates=[],s.chatReplayDedupKeys.clear(),s.hasAgentAssistantStream=!1,s.hasAgentThinkingStream=!1,s.hasAgentToolStartStream=!1,s.hasAgentToolResultStream=!1,s.chatAssistantTextSoFar=void 0,s.agentAssistantTextSoFar=void 0,s.cronJobId=void 0,s.promptDone=!1,t.resetSessionUpdateCount(e),this.enforceToolCallMapLimits()}enforceToolCallMapLimits(){if(this.toolCallIdMap.size>5e3){const s=this.toolCallIdMap.size-5e3;let e=0;for(const t of this.toolCallIdMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(this.toolCallIdMap.get(t)),this.toolCallIdMap.delete(t),e++}}if(this.toolCallToSessionMap.size>5e3){const s=this.toolCallToSessionMap.size-5e3;let e=0;for(const t of this.toolCallToSessionMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(t),e++}}}extractAgentAssistantDelta(s,e){const t=(s.chatAssistantTextSoFar??"").length;if(e.delta){const o=s.agentAssistantTextSoFar??"",i=o+e.delta;if(s.agentAssistantTextSoFar=i,t>0&&o.length<t){if(i.length<=t)return;return i.slice(t)}return e.delta}const o=e.text;if(!o)return;const i=s.agentAssistantTextSoFar??"";s.agentAssistantTextSoFar=o;const n=Math.max(i.length,t);if(!(o.length<=n)){if(o.startsWith(i)||0===n)return o.slice(n)||void 0;if(!i.startsWith(o))return o}}startSessionGc(s,e){this.sessionGcTimer=setInterval(()=>this.gcSessions(s,e),this.SESSION_GC_INTERVAL_MS),"object"==typeof this.sessionGcTimer&&"unref"in this.sessionGcTimer&&this.sessionGcTimer.unref()}gcSessions(s,e){const t=Date.now();let o=0;for(const[i,n]of this.sessions){if(void 0!==n.pendingPromptId)continue;(n.lastStreamActivityTs??n.createdAt)<t-(isExternalInvocationSessionKey(n.gatewaySessionKey)?this.EXTERNAL_SESSION_IDLE_MS:this.MAX_IDLE_SESSION_MS)&&(s(n),e?.(n),this.sessions.delete(i),this.sessionsByGatewayKey.delete(n.gatewaySessionKey),o++)}const i=new Set(Array.from(this.sessions.values()).map(s=>s.sessionId));for(const[s,e]of this.toolCallToSessionMap)i.has(e)||this.toolCallToSessionMap.delete(s);for(const[s,e]of this.toolCallIdMap)this.toolCallToSessionMap.has(e)||this.toolCallIdMap.delete(s);o>0&&(this.lastActiveSessionId&&!this.sessions.has(this.lastActiveSessionId)&&(this.lastActiveSessionId=void 0),this.logger.debug?.(`[session-state] GC evicted ${o} idle sessions, remaining=${this.sessions.size}`))}destroy(){this.sessionGcTimer&&(clearInterval(this.sessionGcTimer),this.sessionGcTimer=null);for(const s of this.sessions.values())this.clearPromptTimeout(s),this.clearStreamIdleTimer(s),s.assistantMq?.close();this.promptsByRunId.clear(),this.promptsByGatewayRequestId.clear(),this.runIdToCronJobId.clear(),this.completedRunIds.clear(),this.toolCallIdMap.clear(),this.toolCallToSessionMap.clear(),this.sessionsByGatewayKey.clear(),this.sessions.clear(),this.lastActiveSessionId=void 0}}
|
|
1
|
+
import{MessageQueue}from"../utils/mq.js";import{isExternalInvocationSessionKey}from"./external-protocol-tags.js";const MAX_TOOL_CALL_MAP_ENTRIES=5e3,MAX_COMPLETED_RUN_IDS=200;export class AcpGatewaySessionState{sessions=new Map;promptsByRunId=new Map;promptsByGatewayRequestId=new Map;sessionsByGatewayKey=new Map;toolCallIdMap=new Map;toolCallToSessionMap=new Map;runIdToCronJobId=new Map;nextToolCallSeq=0;completedRunIds=new Set;lastActiveSessionId;sessionGcTimer=null;MAX_IDLE_SESSION_MS=864e5;EXTERNAL_SESSION_IDLE_MS=3e5;SESSION_GC_INTERVAL_MS=3e5;logger;constructor(s){this.logger=s}findSessionByKey(s){return this.sessionsByGatewayKey.get(s)}resolveSessionFromParams(s){const e="string"==typeof s?.sessionId?s.sessionId:void 0;if(e)return this.sessions.get(e)}isCurrentStreamOwner(s,e){return!e||!!s.currentRunId&&e===s.currentRunId}markRunCompleted(s){if(this.completedRunIds.add(s),this.completedRunIds.size>200){const s=this.completedRunIds.values().next().value;s&&this.completedRunIds.delete(s)}}isRunCompleted(s){return this.completedRunIds.has(s)}setRunCronJobId(s,e){if(this.runIdToCronJobId.set(s,e),this.runIdToCronJobId.size>400){const s=this.runIdToCronJobId.keys().next().value;void 0!==s&&this.runIdToCronJobId.delete(s)}}clearPromptTimeout(s){s.promptTimeoutTimer&&(clearTimeout(s.promptTimeoutTimer),s.promptTimeoutTimer=void 0)}clearStreamIdleTimer(s){s.streamIdleTimer&&(clearTimeout(s.streamIdleTimer),s.streamIdleTimer=void 0)}resetForNewRun(s,e,t){this.clearStreamIdleTimer(s),s.currentRunId&&(this.promptsByRunId.delete(s.currentRunId),this.runIdToCronJobId.delete(s.currentRunId)),s.gatewayRequestId&&this.promptsByGatewayRequestId.delete(s.gatewayRequestId),s.currentRunId=void 0,s.gatewayRequestId=void 0,s.assistantMq&&!s.assistantMq.closed&&(this.logger.warn?.(`[session-state] resetForNewRun discarding stale assistantMq sessionId=${e}`),s.assistantMq.close()),s.assistantMq=new MessageQueue,s.pendingUserTurnUpdates=[],s.chatReplayDedupKeys.clear(),s.hasAgentAssistantStream=!1,s.hasAgentThinkingStream=!1,s.hasAgentToolStartStream=!1,s.hasAgentToolResultStream=!1,s.chatAssistantTextSoFar=void 0,s.agentAssistantTextSoFar=void 0,s.cronJobId=void 0,s.presentedFileArtifactKeys=new Set,s.promptDone=!1,t.resetSessionUpdateCount(e),this.enforceToolCallMapLimits()}enforceToolCallMapLimits(){if(this.toolCallIdMap.size>5e3){const s=this.toolCallIdMap.size-5e3;let e=0;for(const t of this.toolCallIdMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(this.toolCallIdMap.get(t)),this.toolCallIdMap.delete(t),e++}}if(this.toolCallToSessionMap.size>5e3){const s=this.toolCallToSessionMap.size-5e3;let e=0;for(const t of this.toolCallToSessionMap.keys()){if(e>=s)break;this.toolCallToSessionMap.delete(t),e++}}}extractAgentAssistantDelta(s,e){const t=(s.chatAssistantTextSoFar??"").length;if(e.delta){const o=s.agentAssistantTextSoFar??"",i=o+e.delta;if(s.agentAssistantTextSoFar=i,t>0&&o.length<t){if(i.length<=t)return;return i.slice(t)}return e.delta}const o=e.text;if(!o)return;const i=s.agentAssistantTextSoFar??"";s.agentAssistantTextSoFar=o;const n=Math.max(i.length,t);if(!(o.length<=n)){if(o.startsWith(i)||0===n)return o.slice(n)||void 0;if(!i.startsWith(o))return o}}startSessionGc(s,e){this.sessionGcTimer=setInterval(()=>this.gcSessions(s,e),this.SESSION_GC_INTERVAL_MS),"object"==typeof this.sessionGcTimer&&"unref"in this.sessionGcTimer&&this.sessionGcTimer.unref()}gcSessions(s,e){const t=Date.now();let o=0;for(const[i,n]of this.sessions){if(void 0!==n.pendingPromptId)continue;(n.lastStreamActivityTs??n.createdAt)<t-(isExternalInvocationSessionKey(n.gatewaySessionKey)?this.EXTERNAL_SESSION_IDLE_MS:this.MAX_IDLE_SESSION_MS)&&(s(n),e?.(n),this.sessions.delete(i),this.sessionsByGatewayKey.delete(n.gatewaySessionKey),o++)}const i=new Set(Array.from(this.sessions.values()).map(s=>s.sessionId));for(const[s,e]of this.toolCallToSessionMap)i.has(e)||this.toolCallToSessionMap.delete(s);for(const[s,e]of this.toolCallIdMap)this.toolCallToSessionMap.has(e)||this.toolCallIdMap.delete(s);o>0&&(this.lastActiveSessionId&&!this.sessions.has(this.lastActiveSessionId)&&(this.lastActiveSessionId=void 0),this.logger.debug?.(`[session-state] GC evicted ${o} idle sessions, remaining=${this.sessions.size}`))}destroy(){this.sessionGcTimer&&(clearInterval(this.sessionGcTimer),this.sessionGcTimer=null);for(const s of this.sessions.values())this.clearPromptTimeout(s),this.clearStreamIdleTimer(s),s.assistantMq?.close();this.promptsByRunId.clear(),this.promptsByGatewayRequestId.clear(),this.runIdToCronJobId.clear(),this.completedRunIds.clear(),this.toolCallIdMap.clear(),this.toolCallToSessionMap.clear(),this.sessionsByGatewayKey.clear(),this.sessions.clear(),this.lastActiveSessionId=void 0}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readUploadToolResult,writeEnrichmentCache}from"../upload/result-cache.js";import{BEEOS_UPLOAD_TOOL_NAME}from"../upload/tool.js";import{tryExtractLocalFiles}from"../upload/local-file-extractors.js";import{uploadLocalFile,getUploadDeps}from"../upload/upload-file.js";import{asTrimmedNonEmptyString as asStr,isPlainRecord as isRecord}from"../utils/text.js";const strategies=[{name:"beeos_upload_file_cache",canHandle:t=>t.toolName===BEEOS_UPLOAD_TOOL_NAME,resolve:strategyUploadCache},{name:"passthrough_gateway_result",canHandle:()=>!0,resolve:async t=>strategyPassthrough(t)}];export function registerToolResultStrategy(t,e){if(e?.prepend)strategies.unshift(t);else{const e=Math.max(0,strategies.length-1);strategies.splice(e,0,t)}}export function getToolResultStrategies(){return strategies}export async function resolveToolResultBlocks(t){let e=[];for(const o of strategies)if(o.canHandle(t))try{const
|
|
1
|
+
import{readUploadToolResult,writeEnrichmentCache}from"../upload/result-cache.js";import{BEEOS_UPLOAD_TOOL_NAME}from"../upload/tool.js";import{tryExtractLocalFiles}from"../upload/local-file-extractors.js";import{uploadLocalFile,getUploadDeps}from"../upload/upload-file.js";import{asTrimmedNonEmptyString as asStr,isPlainRecord as isRecord}from"../utils/text.js";const strategies=[{name:"beeos_upload_file_cache",canHandle:t=>t.toolName===BEEOS_UPLOAD_TOOL_NAME,resolve:strategyUploadCache},{name:"passthrough_gateway_result",canHandle:()=>!0,resolve:async t=>strategyPassthrough(t)}];export function registerToolResultStrategy(t,e){if(e?.prepend)strategies.unshift(t);else{const e=Math.max(0,strategies.length-1);strategies.splice(e,0,t)}}export function getToolResultStrategies(){return strategies}export async function resolveToolResultBlocks(t){let e=[];for(const o of strategies)if(o.canHandle(t))try{const n=await o.resolve(t);if(n&&n.length>0){e=n;break}}catch{}return enrichWithLocalFileUploads(t,e)}async function enrichWithLocalFileUploads(t,e){const o=getUploadDeps();if(!o)return e;const n=tryExtractLocalFiles(t);if(0===n.length)return e;const r=[...e],l=[];for(const e of n)try{o.logger?.debug?.(`[enrich] Uploading local file ${e.filePath} for toolCallId=${t.toolCallId}`);const n=await uploadLocalFile(e.filePath,o.gatewayUrl,o.keys,{fileNameOverride:e.suggestedName});o.logger?.info?.(`[enrich] Uploaded: fileId=${n.fileId} toolCallId=${t.toolCallId}`);const s={uri:n.uri,name:n.name,...n.mimeType?{mimeType:n.mimeType}:{}};l.push(s),r.push({type:"content",content:{type:"resource_link",...s}}),r.push({type:"content",content:{type:"text",text:`[Auto-uploaded: ${n.name}. Do NOT call beeos_upload_file for this file.]`}})}catch(t){o.logger?.warn?.(`[enrich] Upload failed for ${e.filePath}: ${t instanceof Error?t.message:String(t)}, skipping`)}return l.length>0&&writeEnrichmentCache(t.toolCallId,l).catch(e=>{o.logger?.warn?.(`[enrich] Cache write failed for toolCallId=${t.toolCallId}: ${e instanceof Error?e.message:String(e)}`)}),r}async function strategyUploadCache(t){if(t.toolName!==BEEOS_UPLOAD_TOOL_NAME)return null;let e;try{e=await readUploadToolResult(t.toolCallId)}catch{return null}if(!e)return null;const o=pickUploadPayload(e);return buildToolResultSessionContentBlocks(t.toolName,o??e)}function strategyPassthrough(t){if(t.rawResult&&isRecord(t.rawResult)){const e=buildToolResultSessionContentBlocks(t.toolName,t.rawResult);if(e.length>0)return e}const e=t.rawText?.trim();if(!e)return[];try{const o=JSON.parse(e);if(isRecord(o)){const e=buildToolResultSessionContentBlocks(t.toolName,o);if(e.length>0)return e}}catch{}return[{type:"content",content:{type:"text",text:e}}]}function isUploadPayload(t){return!!isRecord(t)&&(!0===t.ok||!1===t.ok||"files"in t||Array.isArray(t.content)&&t.content.length>0)}function pickUploadPayload(t){if(isRecord(t)){for(const e of["output","result","details"]){const o=t[e];if(isUploadPayload(o))return o;if(isRecord(o))for(const t of["output","result","details"])if(isUploadPayload(o[t]))return o[t]}return Array.isArray(t.content)&&t.content.length>0||isUploadPayload(t)?t:void 0}}export function buildToolResultSessionContentBlocks(t,e){const o=[];if(!isRecord(e))return"string"==typeof e&&e.trim()&&o.push({type:"content",content:{type:"text",text:e.trim()}}),o;const n=buildFileResourceBlocks(e),r=asStr(e.content);if(r){const e=buildBlocksFromJsonText(t,r);if(e.length>0)return appendFileBlocks(e,n)}const l=e.content??e.result;if(Array.isArray(l)){for(const t of l){const e=toContentBlock(t);e&&o.push(e)}if(o.length>0)return appendFileBlocks(o,n)}const s=e.result;if(s&&"object"==typeof s&&!Array.isArray(s)){const t=s.content;if(Array.isArray(t)){for(const e of t){const t=toContentBlock(e);t&&o.push(t)}if(o.length>0)return appendFileBlocks(o,n)}}const i=asStr(e.output)??asStr(e.text)??asStr(e.result)??r??asStr(e.message)??asStr(e.error);return i&&o.push({type:"content",content:{type:"text",text:i}}),appendFileBlocks(o,n)}function buildBlocksFromJsonText(t,e){const o=e.trim();if(!o||!o.startsWith("{")&&!o.startsWith("["))return[];try{return buildToolResultSessionContentBlocks(t,JSON.parse(o))}catch{return[]}}function buildFileResourceBlocks(t){if(!Array.isArray(t.files))return[];const e=[];for(const o of t.files){if(!isRecord(o))continue;const t=asStr(o.fileId)??asStr(o.file_id),n=t?`beeos-file://${t}`:asStr(o.uri)??asStr(o.downloadUrl)??asStr(o.url);if(!n)continue;const r=asStr(o.name)??asStr(o.fileName)??asStr(o.file_name),l=asStr(o.mimeType)??asStr(o.mime_type);e.push({type:"content",content:{type:"resource_link",uri:n,...r?{name:r}:{},...l?{mimeType:l}:{}}})}return e}function appendFileBlocks(t,e){if(0===e.length)return t;const o=new Set(e.map(t=>t.content.uri).filter(Boolean));return[...t.filter(t=>!("text"===t.content.type&&t.content.text&&o.has(t.content.text))),...e]}function toContentBlock(t){if(!isRecord(t))return"string"==typeof t&&t.trim()?{type:"content",content:{type:"text",text:t.trim()}}:null;const e=asStr(t.type);if(!e)return null;if("text"===e){const e=asStr(t.text);return e?{type:"content",content:{type:"text",text:e}}:null}if("resource_link"===e){const e=asStr(t.uri);return e?{type:"content",content:{type:"resource_link",uri:e,...t.name?{name:String(t.name)}:{},...t.mimeType?{mimeType:String(t.mimeType)}:{},...t.title?{title:String(t.title)}:{}}}:null}if("image"===e){const e=asStr(t.data),o=asStr(t.uri);return e||o?{type:"content",content:{type:"image",...e?{data:e}:{},...o?{uri:o}:{},...t.mimeType?{mimeType:String(t.mimeType)}:{}}}:null}return null}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,it}from"vitest";import{buildToolResultSessionContentBlocks}from"./tool-result-payload-strategies.js";describe("buildToolResultSessionContentBlocks",()=>{it("converts structured files to resource_link blocks",()=>{const e=buildToolResultSessionContentBlocks("beeos_get_task",{files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});expect(e).toEqual([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])}),it("does not output duplicate raw URI text when result equals the file URI",()=>{const e=buildToolResultSessionContentBlocks("beeos_get_task",{result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});expect(e).toHaveLength(1),expect(e[0]?.content.type).toBe("resource_link")}),it("keeps ordinary result text alongside structured files",()=>{const e=buildToolResultSessionContentBlocks("beeos_get_task",{result:"Screenshot captured.",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});expect(e).toEqual([{type:"content",content:{type:"text",text:"Screenshot captured."}},{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])}),it("appends files even when the payload also has typed content blocks",()=>{const e=buildToolResultSessionContentBlocks("beeos_get_task",{content:[{type:"text",text:"Screenshot captured."}],files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]});expect(e).toEqual([{type:"content",content:{type:"text",text:"Screenshot captured."}},{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])}),it("extracts structured files from a JSON string content wrapper",()=>{const e=buildToolResultSessionContentBlocks("beeos_get_task",{content:JSON.stringify({result:"beeos-file://file-1",files:[{uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}]})});expect(e).toEqual([{type:"content",content:{type:"resource_link",uri:"beeos-file://file-1",name:"screenshot.jpeg",mimeType:"image/jpeg"}}])})});
|
package/dist/acp-server.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;if(s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`)),i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),void this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length);const a=await this.gateway.agentSend(r,e,n,t);return a?.runId&&i&&(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=a.runId,i.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(a.runId,i)),a}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const a=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:a,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();if(await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`),s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
|
|
1
|
+
import{WebSocketServer,WebSocket}from"ws";import{randomUUID}from"node:crypto";let BridgeClientCtor;import{GatewayClient}from"./gateway-client.js";import{TerminalManager}from"./terminal/session-manager.js";import{AcpTransport}from"./acp-gateway/transport.js";import{AcpGatewaySessionState}from"./acp-gateway/session-state.js";import{AcpGatewayBridgeCore}from"./acp-gateway/bridge-core.js";import{BeeosFileResolver}from"./acp-gateway/beeos-file-resolver.js";import{AcpGatewayEvents}from"./acp-gateway/gateway-events.js";import{TerminalBridgeHandler}from"./terminal/bridge-handler.js";import{isAgentSyncWaitForGateway,syncAgentsToPlatform}from"./agent-sync.js";import{CanvasOrchestrator}from"./canvas-orchestrator.js";import{discoverBridgeFromGW}from"./bridge-discovery.js";import{writeDiscoveryCache}from"./discovery-cache.js";import{MessageClient}from"@beeos-ai/message-sdk";function formatBridgeEventArgs(e){return 0===e.length?"(no args)":e.map((e,t)=>`arg${t}=${formatBridgeEventValue(e)}`).join(" ")}function formatBridgeEventValue(e){if(void 0===e)return"undefined";if(null===e)return"null";if(e instanceof Error){const t=e.stack?e.stack.split("\n").slice(0,4).join(" | "):"";return`Error(name=${e.name},message=${e.message}${t?`,stack=${t}`:""})`}if("object"==typeof e)try{return JSON.stringify(e,jsonReplacerForLogs)}catch{return String(e)}return String(e)}function jsonReplacerForLogs(e,t){return t instanceof Error?{name:t.name,message:t.message,stack:t.stack}:t}export class ACPServer{agentFetchFn;instanceId;wss=null;gateway=null;client=null;config;logger;writeObs;transport;state;core;events;terminalMgr;terminalHandler;canvasOrch;messageClient;msgPrincipalId=null;pendingToolCallSessionKeys=[];thirdPartyChannels;agentSyncRetryTimer=null;agentSyncInFlight=!1;started=!1;bridgeInstance=null;bridgeConsecutiveFailures=0;static RE_DISCOVER_THRESHOLD=2;drainNextDeferredSend(e){const t=e.pendingAgentSends?.shift();t&&(e.pendingAgentSends?.length||(e.pendingAgentSends=void 0),this.agentSend(t.message,t.attachments,e.gatewaySessionKey).catch(e=>this.logger.warn?.("[acp-server] Deferred agentSend failed: %s",e)))}async gatewayRequest(e,t){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");return this.gateway.request(e,t)}async agentSend(e,t,s){if(!this.gateway?.isConnected)throw new Error("Gateway not connected");const n=this.config.gateway.agentId||this.core.getDefaultAgentId();let i,r;if(s?(i=this.state.findSessionByKey(s),r=s,i?this.logger.info?.(`[acp-server] agentSend: resolved session for key=${s} sid=${i.sessionId}`):(i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: created session for key=${s} sid=${i.sessionId}`))):(r=`agent:${n}:ephemeral:${randomUUID()}`,i=this.core.ensureSessionForKey(r),this.logger.info?.(`[acp-server] agentSend: no targetSessionKey, created ephemeral session key=${r}`)),i&&!i.promptDone&&(void 0!==i.pendingPromptId||void 0!==i.currentRunId||void 0!==i.gatewayRequestId))return(i.pendingAgentSends??=[]).push({message:e,attachments:t}),void this.logger.info?.("[acp-server] Session has active run, deferred message (queue=%d)",i.pendingAgentSends.length);const a=await this.gateway.agentSend(r,e,n,t);return a?.runId&&i&&(this.state.resetForNewRun(i,i.sessionId,this.transport),i.currentRunId=a.runId,i.assistantStreamStartedAt=Date.now(),this.state.promptsByRunId.set(a.runId,i)),a}setMessageClient(e,t){this.messageClient.setConfig(e),this.msgPrincipalId=t,this.logger.info?.(`[acp-server] MessageEnvelope v3 streamer ready (principal=${t})`)}getMessageClient(){return this.messageClient}ensureImInboundSession(e,t,s){const n=this.core.ensureSessionForKey(e);n.channelId=t,n.inboundMessageId=s}sendBridgeMessage=e=>{const t=this.client;if(t&&t.readyState===WebSocket.OPEN)try{t.send(JSON.stringify(e))}catch(e){this.logger.debug?.("[acp-server] bridge send failed (socket may be closing):",e)}else this.logger.debug?.("[acp-server] bridge not ready, dropping message")};constructor(e,t,s,n,i,r){this.agentFetchFn=n,this.instanceId=i,this.config=e,this.logger=t,this.writeObs=s??(()=>{}),this.thirdPartyChannels=r??new Set,this.transport=new AcpTransport(this.sendBridgeMessage,this.writeObs,void 0,()=>{const e=this.client;return!!e&&e.readyState===WebSocket.OPEN}),this.state=new AcpGatewaySessionState(this.logger),this.messageClient=new MessageClient({logger:this.logger});const a=this.agentFetchFn&&this.config.agentGatewayUrl?new BeeosFileResolver({agentGatewayUrl:this.config.agentGatewayUrl,agentFetch:this.agentFetchFn,writeObs:this.writeObs}):void 0;this.core=new AcpGatewayBridgeCore({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getGateway:()=>this.gateway,instanceId:this.instanceId,thirdPartyChannels:this.thirdPartyChannels,beeosFileResolver:a,agentFetch:this.agentFetchFn,agentGatewayUrl:this.config.agentGatewayUrl,triggerAgentSync:()=>this.tryAgentSync()}),this.canvasOrch=new CanvasOrchestrator({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,getSessionIdForToolCall:e=>this.getSessionIdForToolCall(e),gateway:null,getDefaultAgentId:()=>this.core.getDefaultAgentId(),resolvePlatformAgentId:e=>this.core.resolvePlatformAgentId(e)??""}),this.events=new AcpGatewayEvents({state:this.state,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs,completePrompt:(e,t)=>this.core.completePrompt(e,t),failPrompt:(e,t,s)=>this.core.failPrompt(e,t,s),schedulePromptTimeout:e=>this.core.schedulePromptTimeout(e),maybeCompletePromptFromChatState:(e,t)=>this.core.maybeCompletePromptFromChatState(e,t),agentFetch:this.agentFetchFn,getMessageClient:()=>this.messageClient,getMsgPrincipalId:()=>this.msgPrincipalId??null,getCanvasState:()=>this.canvasOrch.canvasState,gatewayRequest:(e,t)=>{const s=this.gateway;return s?.isConnected?s.request(e,t):Promise.reject(new Error("gateway not connected"))},cronChannelCache:this.core.cronChannelCache}),this.core.onRunEnded=(e,t)=>{"complete"===t.kind?this.canvasOrch.onPromptComplete(e):this.canvasOrch.onPromptFailed(),this.drainNextDeferredSend(e)},this.terminalMgr=new TerminalManager,this.terminalHandler=new TerminalBridgeHandler({terminalMgr:this.terminalMgr,transport:this.transport,config:this.config,logger:this.logger,writeObs:this.writeObs}),this.terminalHandler.setupListeners(),this.core.handleTerminalRequest=e=>this.terminalHandler.handleRequest(e),this.core.handleTerminalNotification=(e,t)=>this.terminalHandler.handleNotification(e,t),this.core.handleCanvasNotification=(e,t)=>{this.canvasOrch.handleCanvasNotification(e,t)},this.core.getCanvasState=()=>this.canvasOrch.canvasState,this.core.onSessionLoaded=(e,t,s)=>{this.canvasOrch.onSessionLoaded(e,t,s)},this.core.onSkillInstalled=(e,t)=>{this.registerSkillFormSchemas(e,t)},this.core.onSessionDeleted=e=>{this.canvasOrch.onSessionDeleted(e)},this.core.onSessionCleared=(e,t)=>{this.canvasOrch.onSessionCleared(e,t)},this.core.handleSlashCommand=async e=>this.canvasOrch.handleSlashCommand(e),this.core.onPromptStart=()=>{this.canvasOrch.onPromptStart()},this.state.startSessionGc(e=>this.core.cleanupPrompt(e),e=>{this.canvasOrch.canvasState?.removeSession(e.gatewaySessionKey),this.transport.resetSessionUpdateCount(e.sessionId)})}async start(e){if(this.started)return void this.logger.info?.("[acp-server] start() skipped — already started (singleton reuse)");this.started=!0;const t=Date.now();this.logger.info?.("[acp-server] start() begin"),e&&(this.config.bridge.url=e);const s=this.config.bridge.url,n=this.config.keyFile;if(s&&!BridgeClientCtor)try{const e=Date.now(),t=await import("@beeos-ai/bridge-client");BridgeClientCtor=t.BridgeClient,this.logger.info?.(`[acp-server] bridge-client import took ${Date.now()-e}ms`)}catch(e){this.logger.warn?.("[acp-server] Bridge SDK not available")}const i=Date.now();if(await this.connectGateway(),this.logger.info?.(`[acp-server] connectGateway() took ${Date.now()-i}ms`),s&&n&&BridgeClientCtor){Date.now();await this.startBridgeMode(s,n)}else this.logger.warn?.("[acp-server] Bridge not configured; running in standby (no ACP listener). Use handleUpgrade() for noServer mode."),this.core.markAgentSyncDone();this.logger.info?.(`[acp-server] start() total ${Date.now()-t}ms`)}async startBridgeMode(e,t){if(!BridgeClientCtor)throw new Error("@beeos-ai/bridge-client not available");const s=new BridgeClientCtor({bridgeUrl:e,service:this.config.bridge.service||"acp",keyFile:t});this.bridgeInstance=s;const n=()=>this.bridgeInstance===s;s.on("connected",()=>{n()&&(this.bridgeConsecutiveFailures=0,this.logger.info?.("[acp-server] Connected to Bridge (publicKey auth)"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"bridge.connected",severity:"info"}),this.tryAgentSync())}),s.on("disconnected",(...e)=>{if(!n())return;const i=String(e[0]??"unknown"),r=formatBridgeEventArgs(e);if(this.logger.warn?.(`[acp-server] Bridge disconnected: ${i} (detail=${r})`),this.core.initialized=!1,this.bridgeConsecutiveFailures++,this.bridgeConsecutiveFailures>=ACPServer.RE_DISCOVER_THRESHOLD&&this.agentFetchFn&&this.config.agentGatewayUrl){this.bridgeConsecutiveFailures=0;const e=this.config.bridge.url;discoverBridgeFromGW(this.config.agentGatewayUrl,this.agentFetchFn).then(async n=>{n&&n.url!==e&&(this.logger.info?.(`[acp-server] Bridge re-discovery: URL changed ${e} → ${n.url}, switching`),this.config.bridge.url=n.url,writeDiscoveryCache({bridge:{url:n.url,discoveredAt:Date.now()}}),await s.close(!1),await this.startBridgeMode(n.url,t))}).catch(()=>{})}}),s.on("message",(...e)=>{if(!n())return;const t=e[0],s="string"==typeof t?t:Buffer.from(t).toString("utf-8");try{const e=JSON.parse(s);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] Bridge request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message from Bridge")}}),s.on("error",(...e)=>{if(!n())return;const t=formatBridgeEventArgs(e);this.logger.error?.(`[acp-server] Bridge error: ${t}`)});const i={readyState:WebSocket.OPEN,send:(e,t)=>{s.send(e).then(()=>t?.(),e=>{const s=e instanceof Error?e:new Error(String(e));this.logger.warn?.(`[acp-server] bridge.send failed: ${s.message}`),t?.(s)})},close:()=>{s.close().catch(e=>{this.logger.debug?.(`[acp-server] bridge.close failed: ${e instanceof Error?e.message:String(e)}`)})},terminate:()=>{s.close(!1).catch(e=>{this.logger.debug?.(`[acp-server] bridge.terminate failed: ${e instanceof Error?e.message:String(e)}`)})},on:()=>i,once:()=>i,removeListener:()=>i,removeAllListeners:()=>i,ping:()=>{}};Object.defineProperty(i,"readyState",{get:()=>"connected"===s.state?WebSocket.OPEN:WebSocket.CLOSED}),this.client=i;const r=Date.now();try{await s.connect(),this.logger.info?.(`[acp-server] bridge.connect() took ${Date.now()-r}ms`)}catch(e){this.logger.warn?.(`[acp-server] Initial bridge connect failed after ${Date.now()-r}ms (will retry in background): ${e?.message}`)}this.core.initialized=!1,this.logger.info?.("[acp-server] Running in Bridge mode (WS client)")}handleUpgrade(e,t,s){this.wss||(this.wss=new WebSocketServer({noServer:!0}),this.wss.on("connection",(e,t)=>this.handleConnection(e,t))),this.wss.handleUpgrade(e,t,s,t=>{this.wss.emit("connection",t,e)})}async tryAgentSync(e=0){if(this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),!this.client||this.client.readyState!==WebSocket.OPEN)return;if(!this.gateway||!this.agentFetchFn||!this.config.agentGatewayUrl)return void this.core.markAgentSyncDone();if(this.agentSyncInFlight)return;const t=[1e3,2e3,5e3,1e4],s=()=>{const s=e+1,n=t[Math.min(e,t.length-1)],i=Math.round(n/1e3),r=`[acp-server] agent sync retry #${s} in ${i}s`;s>0&&s%30==0?this.logger.warn?.(`[acp-server] agent sync still failing after ${s} attempts — retrying every ${i}s`):s<=t.length?this.logger.info?.(r):this.logger.debug?.(r),this.agentSyncRetryTimer=setTimeout(()=>{this.tryAgentSync(s)},n)},n=Date.now();this.agentSyncInFlight=!0;try{const e=await syncAgentsToPlatform({gateway:this.gateway,agentGatewayUrl:this.config.agentGatewayUrl,capabilities:{canvas:this.config.canvasEnabled,terminal:!0,fileSystem:!0,cron:!0,streaming:!0},agentFetch:this.agentFetchFn,logger:this.logger});isAgentSyncWaitForGateway(e)?this.logger.info?.("[acp-server] agent sync waiting for OpenClaw Gateway connection"):e?(this.core.setAgentMapping(e),this.logger.info?.(`[acp-server] agent mapping loaded: ${[...e.keys()].join(", ")} (sync took ${Date.now()-n}ms)`)):s()}catch(e){this.logger.warn?.(`[acp-server] agent sync failed: ${e}`),s()}finally{this.agentSyncInFlight=!1}}async connectGateway(){this.gateway&&(this.gateway.stop(),this.gateway=null);const e=this.config.gateway;this.gateway=new GatewayClient({url:e.url,token:e.token||"",clientId:e.clientId,openclawCanvasCompat:!!this.config.openclawCanvasCompat,retry:{baseMs:this.config.retry.baseMs,maxMs:this.config.retry.maxMs,maxAttempts:this.config.retry.maxAttempts}}),this.gateway.on("chat",e=>this.events.handleChatEvent(e)),this.gateway.on("agent",e=>this.events.handleAgentEvent(e)),this.gateway.on("cron",e=>this.events.handleCronEvent(e)),this.gateway.on("sessionMessage",e=>this.core.handleSessionMessage(e)),this.gateway.on("response",e=>this.core.handleGatewayResponse(e));const t=this.gateway;this.canvasOrch.updateDeps({gateway:t?{get isConnected(){return t.isConnected},request:(e,t)=>this.gateway.request(e,t),agentSend:(e,t,s)=>this.gateway.agentSend(e,t,s)}:null}),this.config.openclawCanvasCompat&&(this.gateway.on("canvas",e=>this.canvasOrch.handleOpenClawCanvasEvent(e)),this.logger.info?.("[acp-server] OpenClaw native canvas compat enabled — listening for canvas events")),this.gateway.on("connected",()=>{this.logger.info?.("[acp-server] Connected to OpenClaw Gateway"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.connected",severity:"info"}),this.core.resubscribeAllSessionMessages(),this.core.refreshCronIsolatedSessionKeys(),this.tryAgentSync()}),this.gateway.on("disconnected",()=>{this.logger.warn?.("[acp-server] Gateway disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"gateway.disconnected",severity:"warn"}),this.core.cleanupAllPendingPrompts()}),this.gateway.on("reconnecting",e=>{this.logger.info?.(`[acp-server] Reconnecting to Gateway attempt=${e.attempt} delay=${Math.round(e.delayMs)}ms`)}),this.gateway.on("reconnect-exhausted",e=>{this.logger.error?.(`[acp-server] Gateway reconnect exhausted after ${e} attempts`)}),this.gateway.on("error",e=>{this.logger.error?.("[acp-server] Gateway error:",e.message)}),this.gateway.on("transport",e=>{this.logger.info?.(`[acp-server] transport.${e.event} durationMs=${e.durationMs??"?"}`),this.writeObs({component:"gateway-client",domain:"lifecycle",name:`transport.${e.event}`,severity:"connect_fail"===e.event||"error"===e.event?"warn":"debug",payload:e})}),this.gateway.on("reconnect-notify-scheduled",e=>{this.logger.info?.(`[acp-server] Server-initiated reconnect scheduled in ${e.delayMs}ms`)}),this.gateway.on("reconnect-notify-fired",()=>{this.logger.info?.("[acp-server] Server-initiated reconnect firing")});try{await this.gateway.start()}catch(e){this.logger.error?.("[acp-server] Initial Gateway connect failed:",e)}}handleConnection(e,t){if(this.client){this.logger.warn?.("[acp-server] Replacing existing ACP client with new connection (takeover)");const e=this.client;this.client=null;try{e.close(1008,"replaced by new client")}catch{e.terminate()}}this.client=e,this.core.initialized=!1,this.logger.info?.("[acp-server] ACP client connected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.connected",severity:"info"}),e.on("message",e=>{const t="string"==typeof e?e:e.toString("utf-8");try{const e=JSON.parse(t);"id"in e&&void 0!==e.id?this.core.handleRequest(e).catch(e=>{this.logger.error?.(`[acp-server] request failed: ${e instanceof Error?e.message:String(e)}`)}):this.core.handleNotification(e)}catch{this.logger.error?.("[acp-server] Invalid JSON-RPC message")}}),e.on("close",()=>{this.client===e&&(this.client=null,this.core.initialized=!1),this.logger.info?.("[acp-server] ACP client disconnected"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"client.disconnected",severity:"info"})})}setCanvasState(e,t){this.canvasOrch.setCanvasState(e,t)}wireRelayActionHandler(){this.canvasOrch.wireRelayActionHandler()}isCanvasActive(e){return this.canvasOrch.isCanvasActive(e)}isCanvasActiveForGatewayKey(e){return this.canvasOrch.isCanvasActiveForGatewayKey(e)}broadcastNotification(e,t){this.transport.sendNotification(e,t)}getActiveSessionId(){return this.state.lastActiveSessionId}getSessionIdForToolCall(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(t){const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);if(s?.sessionId)return e&&this.state.toolCallToSessionMap.set(e,s.sessionId),s.sessionId}if(e){const t=this.state.toolCallToSessionMap.get(e);if(t)return t;const s=this.state.toolCallIdMap.get(e);if(s&&s!==e){const e=this.state.toolCallToSessionMap.get(s);if(e)return e}const n=e.split("_");if(n.length>=3&&"tc"===n[0]){const e=n[1],t=this.state.promptsByRunId.get(e);if(t?.sessionId)return t.sessionId}}this.logger.warn?.(`[acp-server] getSessionIdForToolCall MISS: toolCallId=${e??"(none)"}, sessions=${this.state.sessions.size}, pendingKey=${t??"none"}`)}getSessionIdForActivePrompt(){return this.getSessionIdForToolCall()}setPendingToolCallSessionKey(e){e&&(this.pendingToolCallSessionKeys.push(e),this.pendingToolCallSessionKeys.length>16&&this.pendingToolCallSessionKeys.shift())}preMapToolCallSession(e){const t=this.pendingToolCallSessionKeys.length>0?this.pendingToolCallSessionKeys[this.pendingToolCallSessionKeys.length-1]:void 0;if(!t||!e)return;if(this.state.toolCallToSessionMap.has(e))return;const s=this.state.findSessionByKey(t)??this.core.ensureSessionForKey(t);s?.sessionId&&this.state.toolCallToSessionMap.set(e,s.sessionId)}waitForAgentMapping(){return this.core.waitForAgentMapping()}onceAgentMappingReady(e){this.core.onceAgentMappingReady(e)}onAgentMappingUpdated(e){this.core.onAgentMappingUpdated(e)}flushPendingCanvasLinks(){this.canvasOrch.flushPendingCanvasLinks()}onRelayReconnected(){this.canvasOrch.onRelayReconnected()}getLocalAgentId(){return this.config.gateway.agentId||this.core.getDefaultAgentId()}getDefaultPlatformAgentId(){return this.getDefaultPlatformAgent()?.id}getDefaultPlatformAgent(){const e=this.core.agentMapping;if(e&&0!==e.size)return e.values().next().value??void 0}getPlatformAgentMappings(){const e=this.core.agentMapping;return e&&0!==e.size?[...e.entries()].map(([e,t])=>({localAgentId:e,platformAgent:t})):[]}getPlatformAgentIdForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return this.core.resolvePlatformAgentId(s?.agentId??"")??s?.platformAgentId}getGatewaySessionKeyForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.gatewaySessionKey}getMentionsForToolCall(e){const t=this.getSessionIdForToolCall(e);if(!t)return;const s=this.state.sessions.get(t);return s?.activeMentions}getResourceRenderer(){return this.canvasOrch.resourceRenderer}getDescriptorRegistry(){return this.canvasOrch.descriptorRegistry}getFormBuilder(){return this.canvasOrch.formBuilder}getSchemaRegistry(){return this.canvasOrch.schemaRegistry}registerSkillFormSchemas(e,t){const s=this.canvasOrch.schemaRegistry;s&&import("node:fs/promises").then(async({readFile:n})=>{try{const i=`${t}/skill.json`,r=await n(i,"utf-8"),a=JSON.parse(r);if(a.formSchemas?.length){for(const e of a.formSchemas)s.register({...e,submitLabel:e.submitLabel||"Submit",fields:e.fields});this.logger.info?.(`[acp-server] Registered ${a.formSchemas.length} form schema(s) from skill "${e}"`)}}catch{}}).catch(e=>{this.logger.debug?.(`[acp-server] skill form schema import failed: ${e instanceof Error?e.message:String(e)}`)})}emitCanvasActionAsUserMessage(e,t){this.canvasOrch.emitCanvasActionAsUserMessage(e,t)}async stop(){if(this.started=!1,this.agentSyncRetryTimer&&(clearTimeout(this.agentSyncRetryTimer),this.agentSyncRetryTimer=null),this.state.destroy(),this.canvasOrch.destroy(),this.gateway&&(this.gateway.stop(),this.gateway.removeAllListeners(),this.gateway=null),this.client&&(this.client.removeAllListeners(),this.client.close(),this.client=null),this.bridgeInstance){try{await this.bridgeInstance.close()}catch(e){this.logger.debug?.(`[acp-server] bridge.close failed during stop: ${e instanceof Error?e.message:String(e)}`)}this.bridgeInstance=null}this.bridgeConsecutiveFailures=0,this.terminalHandler.destroy(),this.terminalMgr.destroy(),this.wss&&(this.wss.removeAllListeners(),this.wss.close(),this.wss=null),this.logger.info?.("[acp-server] Stopped"),this.writeObs({component:"acp-server",domain:"lifecycle",name:"lifecycle.shutdown",severity:"info"})}}
|
package/dist/canvas/tools.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const A2UI_TYPE_NAMES=new Set(["Text","Image","Icon","Video","AudioPlayer","Row","Column","List","Card","Tabs","Divider","Modal","Button","CheckBox","TextField","DateTimeInput","MultipleChoice","Slider","Chart","WebEmbed","FilePreview","MapView"]);function normalizeComponents(e){return e.map(e=>{if(!e||"object"!=typeof e)return e;const t=e;if(t.component&&"object"==typeof t.component)return t;for(const e of Object.keys(t))if(A2UI_TYPE_NAMES.has(e)&&"object"==typeof t[e])return{id:t.id,component:{[e]:t[e]}};return t})}export const RENDER_UI_TOOL_NAME="render_ui";export const CLOSE_UI_TOOL_NAME="close_ui";export const RENDER_UI_TOOL_DESCRIPTION="Render a Google A2UI v0.8 component tree to the Canvas panel. Output a flat array of ComponentInstance objects with id references. ALWAYS prefer this over the canvas tool for displaying structured UI.";export const RENDER_UI_TOOL_SCHEMA={type:"object",required:["id","root","components"],properties:{id:{type:"string",description:"Surface ID (e.g. 'sales-report'). Reuse same id to update an existing surface."},root:{type:"string",description:"ID of the root component (must reference one of the components below)."},components:{type:"array",description:"Flat array of A2UI ComponentInstance objects. Each has a unique id and exactly one component type key.",items:{type:"object",required:["id"],properties:{id:{type:"string",description:"Unique component ID referenced by parent via child/children."},component:{type:"object",description:"Exactly ONE key = component type (Text, Image, Icon, Video, AudioPlayer, Row, Column, List, Card, Tabs, Divider, Modal, Button, CheckBox, TextField, DateTimeInput, MultipleChoice, Slider, Chart). Value = that component's properties object."}}}},data:{type:"object",description:"Optional data model for two-way binding. Keys are paths (e.g. '/name'), values are defaults."}}};export const CLOSE_UI_TOOL_DESCRIPTION="Close and remove a UI surface that was previously rendered.";export const CLOSE_UI_TOOL_SCHEMA={type:"object",required:["id"],properties:{id:{type:"string",description:"The UI identifier to close"}}};const SESSION_READY_POLL_ATTEMPTS=3,SESSION_READY_POLL_INTERVAL_MS=400,delay=e=>new Promise(t=>setTimeout(t,e));async function ensureRelayConnected(e,t,n,a){const{stateManager:o,logger:i}=e,r=a??e.getGatewaySessionKey?.(n),s=r??t,l=o.resolveCanvasForSession(s);if(l&&o.isCanvasJoined(l))return l;let c=o.getRelayClient();if(c||(c=await o.waitForRelayClient(),c))try{e.waitForAgentSync&&await e.waitForAgentSync();const a=e.getPlatformAgentId?.(n),l=r??t,d=r&&r!==t?t:void 0,p=await c.ensureCanvas(l,{agentId:a,linkSessionId:d});return await o.joinCanvas(p,s),i.info?.(`[canvas] Joined canvas=${p} session=${t} gatewayKey=${r??"none"} agentId=${a??"none"}`),p}catch(e){return void i.warn?.(`[canvas] Failed to join canvas relay: ${e}`)}}export function createRenderUiHandler(e){const{stateManager:t,logger:n,writeObs:a,isCanvasActive:o,getGatewaySessionKey:i}=e;let r=0;return async(s,l)=>{if(o){const e=i?.(s);if(!o(e))return{success:!1,error:"Canvas is disabled by user. Respond with plain text instead."}}const c=l,d=c.id,p=c.root,u="panel";let m=c.components;if("string"==typeof m)try{m=JSON.parse(m)}catch{}if(!(d&&p&&Array.isArray(m)&&m.length))return{success:!1,error:"id, root, and components (array) are required"};const h=normalizeComponents(m);if(!t.validateComponentCount(h))return{success:!1,error:"Too many components (max 500)"};if(!t.checkRateLimit(d))return n.warn?.(`[canvas] Rate limited surfaceId=${d}`),a({component:"canvas",domain:"canvas",name:"canvas.rate_limited",severity:"warn",payload:{surfaceId:d}}),{success:!1,error:"Rate limited — too many updates per second"};const g=c.data;let y=e.getActiveSessionId?.(s);for(let t=0;!y&&t<3;t++)await delay(400),y=e.getActiveSessionId?.(s);if(!y&&e.waitForAgentSync){try{await e.waitForAgentSync()}catch{}y=e.getActiveSessionId?.(s)}if(!y)return n.warn?.(`[canvas] No active session for toolCallId=${s} after 3 polls, render_ui not ready`),{success:!1,retryable:!0,error:"Canvas session not ready yet (agent just connected) — retry render_ui shortly; do NOT fall back to plain text."};const f=e.getGatewaySessionKey?.(s),v=await ensureRelayConnected(e,y,s,f),x=t.has(d,v??void 0),b=Date.now(),C=t.getRelayClient();v&&C&&b-r>6e4&&C.createSnapshot(v,"Pre-agent edit").then(()=>{r=Date.now(),n.debug?.("[canvas] Pre-edit checkpoint created")}).catch(e=>n.warn?.(`[canvas] Pre-edit checkpoint failed: ${e}`)),x?t.update(d,h,v,p,g):t.create(v??`_fallback_${Date.now()}`,d,u,h,{origin:{kind:"agent"},rootComponentId:p,dataModel:g});const S=h.map(e=>e.component&&"object"==typeof e.component?Object.keys(e.component)[0]??"unknown":"unknown");a({component:"canvas",domain:"canvas",name:x?"canvas.update_sent":"canvas.surface_created",severity:"info",payload:{surfaceId:d,presentation:u,componentCount:h.length,componentTypes:S}}),n.debug?.(`[canvas] ${x?"Updated":"Created"} surface=${d} components=${h.length}`);const T={success:!0,surfaceId:d,presentation:u,isUpdate:x,activeSurfaces:v?t.getAllActive(v).map(e=>e.surfaceId):[d]};return g&&(T.dataModel=g),T}}export function createCloseUiHandler(e){const{stateManager:t,logger:n,writeObs:a}=e;return async(o,i)=>{const r=i.id;if(!r)return{success:!1,error:"id is required"};const s=t.get(r),l=s?Date.now()-s.createdAt:0,c=e.getGatewaySessionKey?.(o),d=c?t.resolveCanvasForSession(c):void 0;return t.delete(r,d),a({component:"canvas",domain:"canvas",name:"canvas.surface_closed",severity:"info",payload:{surfaceId:r,lifetime_ms:l}}),n.debug?.(`[canvas] Closed surface=${r}`),{success:!0,surfaceId:r,message:"UI closed"}}}export const CANVAS_SYSTEM_PROMPT='## Canvas UI — Google A2UI v0.8\n\n**IMPORTANT: Always use `render_ui` (not the built-in `canvas` tool) to display visual/structured content.**\n\n### How render_ui works\n\nYou output a **flat array of components** with unique IDs. Components reference each other by ID.\n\nParameters:\n- `id`: Surface ID (e.g. "sales-report"). Reuse same id to update.\n- `root`: The ID of the top-level component (usually a Column).\n- `components`: Flat array of objects. **Each object MUST have `id` and `component`**:\n `{"id": "myId", "component": {"Text": {"text": {"literalString": "Hello"}, "usageHint": "body"}}}`\n The `component` key wraps exactly ONE type key (Text, Card, Row, etc.) and its properties.\n- `data` (optional): Data model object for form two-way binding.\n\n### Rules\n- Keep chat reply to ONE short sentence when using render_ui. Do NOT repeat data shown in canvas.\n- Use render_ui for structured data, visualizations, forms, dashboards. Use plain text for explanations.\n- Canvas renders in a narrow sidebar (~400px).\n- Prefer fewer, larger components. One table is better than many small cards.\n- Use descriptive surface IDs (e.g. "sales-report", not "ui1").\n- Same `id` updates existing surface; different `id` creates new one.\n- When asked to modify existing content, reuse the original surface id from [Canvas State].\n\n### StringValue\n\nText properties use StringValue — an object with exactly one key:\n- Static text: `{"literalString": "Hello"}`\n- Data binding: `{"path": "/fieldName"}` (reads from the `data` model)\n\nShorthand: `sv("text")` means `{"literalString": "text"}` in examples below.\n\n### Component Reference\n\n**Text** — Display text with semantic sizing.\n`{Text: {text: sv("Title"), usageHint: "h2"}}`\nusageHint: "h1" | "h2" | "h3" | "h4" | "h5" | "caption" | "body"\ntext also renders Markdown (bold, italic, lists, code blocks, links).\n\n**Image** — Display an image.\n`{Image: {url: sv("https://..."), usageHint: "mediumFeature"}}`\nurl may be an http(s), data/blob, or BeeOS file URI such as `beeos-file://<fileId>`; the renderer resolves BeeOS file URIs when the viewing surface has permission.\nusageHint: "icon" | "avatar" | "smallFeature" | "mediumFeature" | "largeFeature" | "header"\nfit (optional): "contain" | "cover" | "fill" | "none" | "scale-down"\n\n**Icon** — Material icon.\n`{Icon: {name: sv("check")}}`\n\n**Video** — Embed a video.\n`{Video: {url: sv("https://youtube.com/...")}}`\n\n**AudioPlayer** — Audio playback.\n`{AudioPlayer: {url: sv("https://..."), description: sv("Episode 1")}}`\n\n**Row** — Horizontal layout. Children referenced by ID.\n`{Row: {children: {explicitList: ["child1", "child2"]}, distribution: "spaceBetween"}}`\ndistribution: "start" | "center" | "end" | "spaceBetween" | "spaceAround" | "spaceEvenly"\nalignment: "start" | "center" | "end" | "stretch"\n\n**Column** — Vertical layout. Children referenced by ID.\n`{Column: {children: {explicitList: ["child1", "child2"]}}}`\nSame distribution/alignment options as Row.\n\n**List** — Vertical or horizontal list.\n`{List: {children: {explicitList: ["item1", "item2"]}, direction: "vertical"}}`\n\n**Card** — Container with a single child.\n`{Card: {child: "contentColumnId"}}`\n\n**Tabs** — Tabbed container.\n`{Tabs: {tabItems: [{title: sv("Tab 1"), child: "panel1"}, {title: sv("Tab 2"), child: "panel2"}]}}`\n\n**Divider** — Horizontal or vertical separator.\n`{Divider: {axis: "horizontal"}}`\n\n**Modal** — Dialog with trigger.\n`{Modal: {entryPointChild: "triggerBtnId", contentChild: "dialogContentId"}}`\n\n**Button** — Clickable with action.\n`{Button: {child: "labelTextId", action: {name: "onClick", context: [{key: "id", value: {literalString: "item-1"}}]}}}`\n\n**TextField** — Text input with data binding.\n`{TextField: {label: sv("Name"), text: {path: "/name"}, type: "shortText"}}`\ntype: "shortText" | "number" | "date" | "longText"\n\n**CheckBox** — Boolean toggle.\n`{CheckBox: {label: sv("Agree"), value: {path: "/agree"}}}`\n\n**DateTimeInput** — Date/time picker.\n`{DateTimeInput: {value: {path: "/date"}, enableDate: true, enableTime: false}}`\n\n**MultipleChoice** — Selection from options.\n`{MultipleChoice: {selections: {path: "/country"}, options: [{label: sv("US"), value: "US"}, {label: sv("CN"), value: "CN"}]}}`\n\n**Slider** — Numeric range input.\n`{Slider: {value: {path: "/volume"}, minValue: 0, maxValue: 100}}`\n\n**Chart** (BeeOS extension) — Data visualization.\n`{Chart: {chartType: sv("bar"), title: sv("Sales"), labels: ["Q1","Q2","Q3"], datasets: [{label: "Revenue", data: [100,200,150]}]}}`\nchartType: bar, horizontal_bar, line, area, scatter, pie, doughnut, radar, gauge, candlestick, funnel, sparkline\ngauge uses value + max instead of labels/datasets.\ncandlestick data: [{open,high,low,close}] (full field names, NOT o/h/l/c).\n\n### Composition Patterns\n\n**Simple text display:**\n```json\n{"id":"s1","root":"r","components":[\n {"id":"t","component":{"Text":{"text":{"literalString":"Hello World"},"usageHint":"h2"}}},\n {"id":"r","component":{"Column":{"children":{"explicitList":["t"]}}}}\n]}\n```\n\n**Dashboard with KPI cards:**\n```json\n{"id":"dashboard","root":"root","components":[\n {"id":"title","component":{"Text":{"text":{"literalString":"Dashboard"},"usageHint":"h2"}}},\n {"id":"rev-val","component":{"Text":{"text":{"literalString":"$1.2M"},"usageHint":"h2"}}},\n {"id":"rev-lbl","component":{"Text":{"text":{"literalString":"Revenue"},"usageHint":"caption"}}},\n {"id":"rev-col","component":{"Column":{"children":{"explicitList":["rev-lbl","rev-val"]},"alignment":"center"}}},\n {"id":"rev-card","component":{"Card":{"child":"rev-col"}}},\n {"id":"users-val","component":{"Text":{"text":{"literalString":"8,432"},"usageHint":"h2"}}},\n {"id":"users-lbl","component":{"Text":{"text":{"literalString":"Users"},"usageHint":"caption"}}},\n {"id":"users-col","component":{"Column":{"children":{"explicitList":["users-lbl","users-val"]},"alignment":"center"}}},\n {"id":"users-card","component":{"Card":{"child":"users-col"}}},\n {"id":"kpi-row","component":{"Row":{"children":{"explicitList":["rev-card","users-card"]}}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["title","kpi-row"]}}}}\n]}\n```\n\n**Table layout (Column + Row + Text):**\n```json\n{"id":"users-table","root":"root","components":[\n {"id":"h-name","component":{"Text":{"text":{"literalString":"Name"},"usageHint":"caption"}}},\n {"id":"h-role","component":{"Text":{"text":{"literalString":"Role"},"usageHint":"caption"}}},\n {"id":"header","component":{"Row":{"children":{"explicitList":["h-name","h-role"]}}}},\n {"id":"div","component":{"Divider":{"axis":"horizontal"}}},\n {"id":"r1-name","component":{"Text":{"text":{"literalString":"Alice"},"usageHint":"body"}}},\n {"id":"r1-role","component":{"Text":{"text":{"literalString":"Engineer"},"usageHint":"body"}}},\n {"id":"row1","component":{"Row":{"children":{"explicitList":["r1-name","r1-role"]}}}},\n {"id":"r2-name","component":{"Text":{"text":{"literalString":"Bob"},"usageHint":"body"}}},\n {"id":"r2-role","component":{"Text":{"text":{"literalString":"Designer"},"usageHint":"body"}}},\n {"id":"row2","component":{"Row":{"children":{"explicitList":["r2-name","r2-role"]}}}},\n {"id":"table-col","component":{"Column":{"children":{"explicitList":["header","div","row1","row2"]}}}},\n {"id":"root","component":{"Card":{"child":"table-col"}}}\n]}\n```\n\n**Form with data binding:**\n```json\n{"id":"settings","root":"root","data":{"/name":"","/email":"","/notify":false},"components":[\n {"id":"title","component":{"Text":{"text":{"literalString":"Settings"},"usageHint":"h3"}}},\n {"id":"f-name","component":{"TextField":{"label":{"literalString":"Name"},"text":{"path":"/name"},"type":"shortText"}}},\n {"id":"f-email","component":{"TextField":{"label":{"literalString":"Email"},"text":{"path":"/email"},"type":"shortText"}}},\n {"id":"f-notify","component":{"CheckBox":{"label":{"literalString":"Email notifications"},"value":{"path":"/notify"}}}},\n {"id":"btn-text","component":{"Text":{"text":{"literalString":"Save"},"usageHint":"body"}}},\n {"id":"btn","component":{"Button":{"child":"btn-text","action":{"name":"save_settings","context":[{"key":"name","value":{"path":"/name"}},{"key":"email","value":{"path":"/email"}},{"key":"notify","value":{"path":"/notify"}}]}}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["title","f-name","f-email","f-notify","btn"]}}}}\n]}\n```\n\n**Chart:**\n```json\n{"id":"sales-chart","root":"root","components":[\n {"id":"chart","component":{"Chart":{"chartType":{"literalString":"bar"},"title":{"literalString":"Monthly Sales"},"labels":["Jan","Feb","Mar"],"datasets":[{"label":"Revenue","data":[100,200,150]}]}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["chart"]}}}}\n]}\n```\n\n### render_form\nUse `render_form` for schema-driven forms when a domain schema exists.\nParameters: `domain`, `operation`, `data`, `entityId`.\n\n### close_ui\nCloses a UI surface. Use when user moves to a new topic or explicitly dismisses.\n';export const CANVAS_TERM_MAPPING='[System Note] Always use `render_ui` (not the canvas tool) for visual/structured content. Each component in the array MUST be: {"id":"x","component":{"TypeName":{props}}} — the "component" wrapper key is required. render_ui uses Google A2UI v0.8 format: flat component array with id references. Components: Text (usageHint: h1-h5/caption/body), Image, Icon, Video, AudioPlayer, Row (horizontal layout, children.explicitList), Column (vertical layout, children.explicitList), List, Card (single child), Tabs (tabItems), Divider, Modal, Button (child + action), TextField, CheckBox, MultipleChoice, Slider, DateTimeInput, Chart (BeeOS extension: chartType, labels, datasets). StringValue: {"literalString":"text"} or {"path":"/binding"}. root param = top-level component ID. Tables: Card > Column > Row > Text. Forms: TextField/CheckBox/Slider + Button with action.context paths. Chart: chartType(bar/line/pie/area/scatter/doughnut/radar/funnel/gauge/candlestick/sparkline), labels, datasets:[{label,data,color?}]. Candlestick: [{open,high,low,close}]. Gauge: value+max. Keep chat brief when canvas is used. Use `close_ui` to dismiss. Reuse same id to update; new id for new content.';export const CANVAS_UPDATE_HINT="[Canvas Rule] Same id in render_ui = update existing surface. Different id = new surface. When a surface is referenced for editing, you MUST reuse its exact id and provide the full updated component tree.";const MAX_CONTEXT_SURFACES=8,MAX_CONTEXT_CHARS=500;export function buildActiveSurfacesContext(e,t){let n;if(!t)return null;if(n=e.getAllActive(t),0===n.length)return null;const a=[...n].sort((e,t)=>t.updatedAt-e.updatedAt).slice(0,8),o=n.length-a.length;let i=`[Canvas State] Active surfaces: ${a.map(t=>{const n=e.summarize(t.surfaceId);if(!n)return`"${t.surfaceId}"`;const a=n.types.slice(0,3).join(", "),o=n.title?`: "${n.title}"`:"";return`"${t.surfaceId}" (${a}${o})`}).join("; ")}.`;return o>0&&(i+=` ...and ${o} more.`),i+=" To modify existing content, reuse its id in render_ui.",i.length>500&&(i=i.slice(0,497)+"..."),i}
|
|
1
|
+
const A2UI_TYPE_NAMES=new Set(["Text","Image","Icon","Video","AudioPlayer","Row","Column","List","Card","Tabs","Divider","Modal","Button","CheckBox","TextField","DateTimeInput","MultipleChoice","Slider","Chart","WebEmbed","FilePreview","MapView"]);function normalizeComponents(e){return e.map(e=>{if(!e||"object"!=typeof e)return e;const t=e;if(t.component&&"object"==typeof t.component)return t;for(const e of Object.keys(t))if(A2UI_TYPE_NAMES.has(e)&&"object"==typeof t[e])return{id:t.id,component:{[e]:t[e]}};return t})}export const RENDER_UI_TOOL_NAME="render_ui";export const CLOSE_UI_TOOL_NAME="close_ui";export const RENDER_UI_TOOL_DESCRIPTION="Render a Google A2UI v0.8 component tree to the Canvas panel. Output a flat array of ComponentInstance objects with id references. ALWAYS prefer this over the canvas tool for displaying structured UI.";export const RENDER_UI_TOOL_SCHEMA={type:"object",required:["id","root","components"],properties:{id:{type:"string",description:"Surface ID (e.g. 'sales-report'). Reuse same id to update an existing surface."},root:{type:"string",description:"ID of the root component (must reference one of the components below)."},components:{type:"array",description:"Flat array of A2UI ComponentInstance objects. Each has a unique id and exactly one component type key.",items:{type:"object",required:["id"],properties:{id:{type:"string",description:"Unique component ID referenced by parent via child/children."},component:{type:"object",description:"Exactly ONE key = component type (Text, Image, Icon, Video, AudioPlayer, Row, Column, List, Card, Tabs, Divider, Modal, Button, CheckBox, TextField, DateTimeInput, MultipleChoice, Slider, Chart). Value = that component's properties object."}}}},data:{type:"object",description:"Optional data model for two-way binding. Keys are paths (e.g. '/name'), values are defaults."}}};export const CLOSE_UI_TOOL_DESCRIPTION="Close and remove a UI surface that was previously rendered.";export const CLOSE_UI_TOOL_SCHEMA={type:"object",required:["id"],properties:{id:{type:"string",description:"The UI identifier to close"}}};const SESSION_READY_POLL_ATTEMPTS=3,SESSION_READY_POLL_INTERVAL_MS=400,delay=e=>new Promise(t=>setTimeout(t,e));async function ensureRelayConnected(e,t,n,a){const{stateManager:o,logger:i}=e,r=a??e.getGatewaySessionKey?.(n),s=r??t,l=o.resolveCanvasForSession(s);if(l&&o.isCanvasJoined(l))return l;let c=o.getRelayClient();if(c||(c=await o.waitForRelayClient(),c))try{e.waitForAgentSync&&await e.waitForAgentSync();const a=e.getPlatformAgentId?.(n),l=r??t,d=r&&r!==t?t:void 0,p=await c.ensureCanvas(l,{agentId:a,linkSessionId:d});return await o.joinCanvas(p,s),i.info?.(`[canvas] Joined canvas=${p} session=${t} gatewayKey=${r??"none"} agentId=${a??"none"}`),p}catch(e){return void i.warn?.(`[canvas] Failed to join canvas relay: ${e}`)}}export function createRenderUiHandler(e){const{stateManager:t,logger:n,writeObs:a,isCanvasActive:o,getGatewaySessionKey:i}=e;let r=0;return async(s,l)=>{if(o){const e=i?.(s);if(!o(e))return{success:!1,error:"Canvas is disabled by user. Respond with plain text instead."}}const c=l,d=c.id,p=c.root,u="panel";let m=c.components;if("string"==typeof m)try{m=JSON.parse(m)}catch{}if(!(d&&p&&Array.isArray(m)&&m.length))return{success:!1,error:"id, root, and components (array) are required"};const h=normalizeComponents(m);if(!t.validateComponentCount(h))return{success:!1,error:"Too many components (max 500)"};if(!t.checkRateLimit(d))return n.warn?.(`[canvas] Rate limited surfaceId=${d}`),a({component:"canvas",domain:"canvas",name:"canvas.rate_limited",severity:"warn",payload:{surfaceId:d}}),{success:!1,error:"Rate limited — too many updates per second"};const g=c.data;let y=e.getActiveSessionId?.(s);for(let t=0;!y&&t<3;t++)await delay(400),y=e.getActiveSessionId?.(s);if(!y&&e.waitForAgentSync){try{await e.waitForAgentSync()}catch{}y=e.getActiveSessionId?.(s)}if(!y)return n.warn?.(`[canvas] No active session for toolCallId=${s} after 3 polls, render_ui not ready`),{success:!1,retryable:!0,error:"Canvas session not ready yet (agent just connected) — retry render_ui shortly; do NOT fall back to plain text."};const f=e.getGatewaySessionKey?.(s),v=await ensureRelayConnected(e,y,s,f),x=t.has(d,v??void 0),b=Date.now(),C=t.getRelayClient();v&&C&&b-r>6e4&&C.createSnapshot(v,"Pre-agent edit").then(()=>{r=Date.now(),n.debug?.("[canvas] Pre-edit checkpoint created")}).catch(e=>n.warn?.(`[canvas] Pre-edit checkpoint failed: ${e}`)),x?t.update(d,h,v,p,g):t.create(v??`_fallback_${Date.now()}`,d,u,h,{origin:{kind:"agent"},rootComponentId:p,dataModel:g});const S=h.map(e=>e.component&&"object"==typeof e.component?Object.keys(e.component)[0]??"unknown":"unknown");a({component:"canvas",domain:"canvas",name:x?"canvas.update_sent":"canvas.surface_created",severity:"info",payload:{surfaceId:d,presentation:u,componentCount:h.length,componentTypes:S}}),n.debug?.(`[canvas] ${x?"Updated":"Created"} surface=${d} components=${h.length}`);const T={success:!0,surfaceId:d,presentation:u,isUpdate:x,activeSurfaces:v?t.getAllActive(v).map(e=>e.surfaceId):[d]};return g&&(T.dataModel=g),T}}export function createCloseUiHandler(e){const{stateManager:t,logger:n,writeObs:a}=e;return async(o,i)=>{const r=i.id;if(!r)return{success:!1,error:"id is required"};const s=t.get(r),l=s?Date.now()-s.createdAt:0,c=e.getGatewaySessionKey?.(o),d=c?t.resolveCanvasForSession(c):void 0;return t.delete(r,d),a({component:"canvas",domain:"canvas",name:"canvas.surface_closed",severity:"info",payload:{surfaceId:r,lifetime_ms:l}}),n.debug?.(`[canvas] Closed surface=${r}`),{success:!0,surfaceId:r,message:"UI closed"}}}export const CANVAS_SYSTEM_PROMPT='## Canvas UI — Google A2UI v0.8\n\n**IMPORTANT: Always use `render_ui` (not the built-in `canvas` tool) to display visual/structured content.**\n\n### How render_ui works\n\nYou output a **flat array of components** with unique IDs. Components reference each other by ID.\n\nParameters:\n- `id`: Surface ID (e.g. "sales-report"). Reuse same id to update.\n- `root`: The ID of the top-level component (usually a Column).\n- `components`: Flat array of objects. **Each object MUST have `id` and `component`**:\n `{"id": "myId", "component": {"Text": {"text": {"literalString": "Hello"}, "usageHint": "body"}}}`\n The `component` key wraps exactly ONE type key (Text, Card, Row, etc.) and its properties.\n- `data` (optional): Data model object for form two-way binding.\n\n### Rules\n- Keep chat reply to ONE short sentence when using render_ui. Do NOT repeat data shown in canvas.\n- Use render_ui for structured data, visualizations, forms, dashboards. Use plain text for explanations.\n- Canvas renders in a narrow sidebar (~400px).\n- Prefer fewer, larger components. One table is better than many small cards.\n- Use descriptive surface IDs (e.g. "sales-report", not "ui1").\n- Same `id` updates existing surface; different `id` creates new one.\n- When asked to modify existing content, reuse the original surface id from [Canvas State].\n\n### StringValue\n\nText properties use StringValue — an object with exactly one key:\n- Static text: `{"literalString": "Hello"}`\n- Data binding: `{"path": "/fieldName"}` (reads from the `data` model)\n\nShorthand: `sv("text")` means `{"literalString": "text"}` in examples below.\n\n### Component Reference\n\n**Text** — Display text with semantic sizing.\n`{Text: {text: sv("Title"), usageHint: "h2"}}`\nusageHint: "h1" | "h2" | "h3" | "h4" | "h5" | "caption" | "body"\ntext also renders Markdown (bold, italic, lists, code blocks, links).\n\n**Image** — Display an image.\n`{Image: {url: sv("https://..."), usageHint: "mediumFeature"}}`\nImage.url may be `beeos-file://<fileId>`; the renderer resolves it. Do not convert it to a signed URL.\nusageHint: "icon" | "avatar" | "smallFeature" | "mediumFeature" | "largeFeature" | "header"\nfit (optional): "contain" | "cover" | "fill" | "none" | "scale-down"\n\n**Icon** — Material icon.\n`{Icon: {name: sv("check")}}`\n\n**Video** — Embed a video.\n`{Video: {url: sv("https://youtube.com/...")}}`\n\n**AudioPlayer** — Audio playback.\n`{AudioPlayer: {url: sv("https://..."), description: sv("Episode 1")}}`\n\n**Row** — Horizontal layout. Children referenced by ID.\n`{Row: {children: {explicitList: ["child1", "child2"]}, distribution: "spaceBetween"}}`\ndistribution: "start" | "center" | "end" | "spaceBetween" | "spaceAround" | "spaceEvenly"\nalignment: "start" | "center" | "end" | "stretch"\n\n**Column** — Vertical layout. Children referenced by ID.\n`{Column: {children: {explicitList: ["child1", "child2"]}}}`\nSame distribution/alignment options as Row.\n\n**List** — Vertical or horizontal list.\n`{List: {children: {explicitList: ["item1", "item2"]}, direction: "vertical"}}`\n\n**Card** — Container with a single child.\n`{Card: {child: "contentColumnId"}}`\n\n**Tabs** — Tabbed container.\n`{Tabs: {tabItems: [{title: sv("Tab 1"), child: "panel1"}, {title: sv("Tab 2"), child: "panel2"}]}}`\n\n**Divider** — Horizontal or vertical separator.\n`{Divider: {axis: "horizontal"}}`\n\n**Modal** — Dialog with trigger.\n`{Modal: {entryPointChild: "triggerBtnId", contentChild: "dialogContentId"}}`\n\n**Button** — Clickable with action.\n`{Button: {child: "labelTextId", action: {name: "onClick", context: [{key: "id", value: {literalString: "item-1"}}]}}}`\n\n**TextField** — Text input with data binding.\n`{TextField: {label: sv("Name"), text: {path: "/name"}, type: "shortText"}}`\ntype: "shortText" | "number" | "date" | "longText"\n\n**CheckBox** — Boolean toggle.\n`{CheckBox: {label: sv("Agree"), value: {path: "/agree"}}}`\n\n**DateTimeInput** — Date/time picker.\n`{DateTimeInput: {value: {path: "/date"}, enableDate: true, enableTime: false}}`\n\n**MultipleChoice** — Selection from options.\n`{MultipleChoice: {selections: {path: "/country"}, options: [{label: sv("US"), value: "US"}, {label: sv("CN"), value: "CN"}]}}`\n\n**Slider** — Numeric range input.\n`{Slider: {value: {path: "/volume"}, minValue: 0, maxValue: 100}}`\n\n**Chart** (BeeOS extension) — Data visualization.\n`{Chart: {chartType: sv("bar"), title: sv("Sales"), labels: ["Q1","Q2","Q3"], datasets: [{label: "Revenue", data: [100,200,150]}]}}`\nchartType: bar, horizontal_bar, line, area, scatter, pie, doughnut, radar, gauge, candlestick, funnel, sparkline\ngauge uses value + max instead of labels/datasets.\ncandlestick data: [{open,high,low,close}] (full field names, NOT o/h/l/c).\n\n### Composition Patterns\n\n**Simple text display:**\n```json\n{"id":"s1","root":"r","components":[\n {"id":"t","component":{"Text":{"text":{"literalString":"Hello World"},"usageHint":"h2"}}},\n {"id":"r","component":{"Column":{"children":{"explicitList":["t"]}}}}\n]}\n```\n\n**Dashboard with KPI cards:**\n```json\n{"id":"dashboard","root":"root","components":[\n {"id":"title","component":{"Text":{"text":{"literalString":"Dashboard"},"usageHint":"h2"}}},\n {"id":"rev-val","component":{"Text":{"text":{"literalString":"$1.2M"},"usageHint":"h2"}}},\n {"id":"rev-lbl","component":{"Text":{"text":{"literalString":"Revenue"},"usageHint":"caption"}}},\n {"id":"rev-col","component":{"Column":{"children":{"explicitList":["rev-lbl","rev-val"]},"alignment":"center"}}},\n {"id":"rev-card","component":{"Card":{"child":"rev-col"}}},\n {"id":"users-val","component":{"Text":{"text":{"literalString":"8,432"},"usageHint":"h2"}}},\n {"id":"users-lbl","component":{"Text":{"text":{"literalString":"Users"},"usageHint":"caption"}}},\n {"id":"users-col","component":{"Column":{"children":{"explicitList":["users-lbl","users-val"]},"alignment":"center"}}},\n {"id":"users-card","component":{"Card":{"child":"users-col"}}},\n {"id":"kpi-row","component":{"Row":{"children":{"explicitList":["rev-card","users-card"]}}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["title","kpi-row"]}}}}\n]}\n```\n\n**Table layout (Column + Row + Text):**\n```json\n{"id":"users-table","root":"root","components":[\n {"id":"h-name","component":{"Text":{"text":{"literalString":"Name"},"usageHint":"caption"}}},\n {"id":"h-role","component":{"Text":{"text":{"literalString":"Role"},"usageHint":"caption"}}},\n {"id":"header","component":{"Row":{"children":{"explicitList":["h-name","h-role"]}}}},\n {"id":"div","component":{"Divider":{"axis":"horizontal"}}},\n {"id":"r1-name","component":{"Text":{"text":{"literalString":"Alice"},"usageHint":"body"}}},\n {"id":"r1-role","component":{"Text":{"text":{"literalString":"Engineer"},"usageHint":"body"}}},\n {"id":"row1","component":{"Row":{"children":{"explicitList":["r1-name","r1-role"]}}}},\n {"id":"r2-name","component":{"Text":{"text":{"literalString":"Bob"},"usageHint":"body"}}},\n {"id":"r2-role","component":{"Text":{"text":{"literalString":"Designer"},"usageHint":"body"}}},\n {"id":"row2","component":{"Row":{"children":{"explicitList":["r2-name","r2-role"]}}}},\n {"id":"table-col","component":{"Column":{"children":{"explicitList":["header","div","row1","row2"]}}}},\n {"id":"root","component":{"Card":{"child":"table-col"}}}\n]}\n```\n\n**Form with data binding:**\n```json\n{"id":"settings","root":"root","data":{"/name":"","/email":"","/notify":false},"components":[\n {"id":"title","component":{"Text":{"text":{"literalString":"Settings"},"usageHint":"h3"}}},\n {"id":"f-name","component":{"TextField":{"label":{"literalString":"Name"},"text":{"path":"/name"},"type":"shortText"}}},\n {"id":"f-email","component":{"TextField":{"label":{"literalString":"Email"},"text":{"path":"/email"},"type":"shortText"}}},\n {"id":"f-notify","component":{"CheckBox":{"label":{"literalString":"Email notifications"},"value":{"path":"/notify"}}}},\n {"id":"btn-text","component":{"Text":{"text":{"literalString":"Save"},"usageHint":"body"}}},\n {"id":"btn","component":{"Button":{"child":"btn-text","action":{"name":"save_settings","context":[{"key":"name","value":{"path":"/name"}},{"key":"email","value":{"path":"/email"}},{"key":"notify","value":{"path":"/notify"}}]}}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["title","f-name","f-email","f-notify","btn"]}}}}\n]}\n```\n\n**Chart:**\n```json\n{"id":"sales-chart","root":"root","components":[\n {"id":"chart","component":{"Chart":{"chartType":{"literalString":"bar"},"title":{"literalString":"Monthly Sales"},"labels":["Jan","Feb","Mar"],"datasets":[{"label":"Revenue","data":[100,200,150]}]}}},\n {"id":"root","component":{"Column":{"children":{"explicitList":["chart"]}}}}\n]}\n```\n\n### render_form\nUse `render_form` for schema-driven forms when a domain schema exists.\nParameters: `domain`, `operation`, `data`, `entityId`.\n\n### close_ui\nCloses a UI surface. Use when user moves to a new topic or explicitly dismisses.\n';export const CANVAS_TERM_MAPPING='[System Note] Always use `render_ui` (not the canvas tool) for visual/structured content. Each component in the array MUST be: {"id":"x","component":{"TypeName":{props}}} — the "component" wrapper key is required. render_ui uses Google A2UI v0.8 format: flat component array with id references. Components: Text (usageHint: h1-h5/caption/body), Image, Icon, Video, AudioPlayer, Row (horizontal layout, children.explicitList), Column (vertical layout, children.explicitList), List, Card (single child), Tabs (tabItems), Divider, Modal, Button (child + action), TextField, CheckBox, MultipleChoice, Slider, DateTimeInput, Chart (BeeOS extension: chartType, labels, datasets). StringValue: {"literalString":"text"} or {"path":"/binding"}. root param = top-level component ID. Tables: Card > Column > Row > Text. Forms: TextField/CheckBox/Slider + Button with action.context paths. Chart: chartType(bar/line/pie/area/scatter/doughnut/radar/funnel/gauge/candlestick/sparkline), labels, datasets:[{label,data,color?}]. Candlestick: [{open,high,low,close}]. Gauge: value+max. Keep chat brief when canvas is used. Use `close_ui` to dismiss. Reuse same id to update; new id for new content.';export const CANVAS_UPDATE_HINT="[Canvas Rule] Same id in render_ui = update existing surface. Different id = new surface. When a surface is referenced for editing, you MUST reuse its exact id and provide the full updated component tree.";const MAX_CONTEXT_SURFACES=8,MAX_CONTEXT_CHARS=500;export function buildActiveSurfacesContext(e,t){let n;if(!t)return null;if(n=e.getAllActive(t),0===n.length)return null;const a=[...n].sort((e,t)=>t.updatedAt-e.updatedAt).slice(0,8),o=n.length-a.length;let i=`[Canvas State] Active surfaces: ${a.map(t=>{const n=e.summarize(t.surfaceId);if(!n)return`"${t.surfaceId}"`;const a=n.types.slice(0,3).join(", "),o=n.title?`: "${n.title}"`:"";return`"${t.surfaceId}" (${a}${o})`}).join("; ")}.`;return o>0&&(i+=` ...and ${o} more.`),i+=" To modify existing content, reuse its id in render_ui.",i.length>500&&(i=i.slice(0,497)+"..."),i}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BEEOS_FILE_URI_PREFIX="beeos-file://";export const BEEOS_FILE_RESOURCE_ROUTING_RULE="
|
|
1
|
+
export const BEEOS_FILE_URI_PREFIX="beeos-file://";export const BEEOS_FILE_RESOURCE_ROUTING_RULE="BeeOS file URIs are platform resources, not paths or HTTP URLs. Do not fetch, upload, browser-open, web_fetch, or shell them for display. The runtime presents structured file artifacts. Never write MEDIA:. Fetch only for local byte analysis.";export function isBeeosFileUri(e){return e.startsWith("beeos-file://")}export function parseBeeosFileUri(e){if(!isBeeosFileUri(e))return{};const r=e.slice(13).trim();return r?{fileId:r}:{error:"beeos-file uri requires file_id"}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{describe,expect,it}from"vitest";import{BEEOS_FILE_RESOURCE_ROUTING_RULE,BEEOS_FILE_URI_PREFIX,isBeeosFileUri,parseBeeosFileUri}from"./beeos-file-uri.js";describe("BeeOS file URI helpers",()=>{it("recognizes BeeOS file URIs",()=>{expect(isBeeosFileUri("beeos-file://abc")).toBe(!0),expect(isBeeosFileUri("https://example.com/file")).toBe(!1)}),it("extracts the raw fileId from BeeOS file URIs",()=>{expect(parseBeeosFileUri("beeos-file://abc-123")).toEqual({fileId:"abc-123"})}),it("does not treat other URLs as BeeOS file URIs",()=>{expect(parseBeeosFileUri("https://example.com/file")).toEqual({})}),it("reports missing fileId",()=>{expect(parseBeeosFileUri(BEEOS_FILE_URI_PREFIX)).toEqual({error:"beeos-file uri requires file_id"})}),it("keeps the global routing rule
|
|
1
|
+
import{describe,expect,it}from"vitest";import{BEEOS_FILE_RESOURCE_ROUTING_RULE,BEEOS_FILE_URI_PREFIX,isBeeosFileUri,parseBeeosFileUri}from"./beeos-file-uri.js";describe("BeeOS file URI helpers",()=>{it("recognizes BeeOS file URIs",()=>{expect(isBeeosFileUri("beeos-file://abc")).toBe(!0),expect(isBeeosFileUri("https://example.com/file")).toBe(!1)}),it("extracts the raw fileId from BeeOS file URIs",()=>{expect(parseBeeosFileUri("beeos-file://abc-123")).toEqual({fileId:"abc-123"})}),it("does not treat other URLs as BeeOS file URIs",()=>{expect(parseBeeosFileUri("https://example.com/file")).toEqual({})}),it("reports missing fileId",()=>{expect(parseBeeosFileUri(BEEOS_FILE_URI_PREFIX)).toEqual({error:"beeos-file uri requires file_id"})}),it("keeps the global routing rule focused on the resource contract",()=>{expect(BEEOS_FILE_RESOURCE_ROUTING_RULE).toContain("not paths or HTTP URLs"),expect(BEEOS_FILE_RESOURCE_ROUTING_RULE).toContain("Do not fetch"),expect(BEEOS_FILE_RESOURCE_ROUTING_RULE).toContain("Never write MEDIA:"),expect(BEEOS_FILE_RESOURCE_ROUTING_RULE.length).toBeLessThan(260)})});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beeos-claw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
4
4
|
"description": "OpenClaw plugin that bridges the BeeOS platform with the local OpenClaw Gateway, providing file transfer, ACP bridge, and observability.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "BeeOS <dev@beeos.ai>",
|