@ziggs-ai/agent-sdk 0.1.3
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/README.md +82 -0
- package/package.json +26 -0
- package/src/ConnectionPool.js +133 -0
- package/src/adapters/OpenAIAdapter.js +73 -0
- package/src/adapters/index.js +1 -0
- package/src/agent/Agent.js +121 -0
- package/src/agent/EventQueue.js +68 -0
- package/src/agent/OutboxBuffer.js +62 -0
- package/src/cognition/PromptBuilder.js +312 -0
- package/src/cognition/resolveActionTool.js +12 -0
- package/src/cognition/runTurn.js +578 -0
- package/src/context/applyEffects.js +133 -0
- package/src/context/batch.js +25 -0
- package/src/context/classifyEnvelope.js +82 -0
- package/src/context/routingLabels.js +54 -0
- package/src/createHealthServer.js +28 -0
- package/src/formatters/HistoryFormatter.js +257 -0
- package/src/formatters/TaskFormatter.js +180 -0
- package/src/formatters/index.js +9 -0
- package/src/index.js +76 -0
- package/src/ingress/normalizeIncoming.js +70 -0
- package/src/runLauncher.js +159 -0
- package/src/shared/ids.js +7 -0
- package/src/shared/types.js +86 -0
- package/src/tasks/TaskService.js +247 -0
- package/src/tasks/index.js +9 -0
- package/src/tasks/taskCore.js +229 -0
- package/src/tasks/taskProtocolRegistry.js +22 -0
- package/src/tasks/taskProtocolRunner.js +107 -0
- package/src/tasks/taskProtocolTools.js +87 -0
- package/src/tools/ToolManager.js +79 -0
- package/src/tools/ToolProvider.js +29 -0
- package/src/tools/defineTool.js +82 -0
- package/src/tools/index.js +11 -0
- package/src/utils/jsonExtractor.js +139 -0
- package/src/workflow/AgentMachine.js +250 -0
- package/src/workflow/WorkflowRuntime.js +63 -0
- package/src/workflow/dsl.js +287 -0
- package/src/workflow/motifs.js +435 -0
- package/src/ziggs/runtime.js +192 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @ziggs-ai/agent-sdk
|
|
2
|
+
|
|
3
|
+
**What it is:** A small JavaScript framework for building **autonomous agents** that run on the **Ziggs** platform. You describe behavior as a **workflow** (states, prompts, actions, transitions), wire **tools**, and the SDK handles **LLM calls**, **context**, **tasks**, and optional **WebSocket** connectivity to Ziggs.
|
|
4
|
+
|
|
5
|
+
It is **not** a generic chat wrapper: the core idea is a **lightweight state machine** (`AgentMachine`) that walks your definition, runs **`runTurn`** for "thinking" steps (prompt + structured actions), and updates **context** from events and `transitions` so routing stays explicit and predictable.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Mental model
|
|
10
|
+
|
|
11
|
+
| Piece | Role |
|
|
12
|
+
|--------|------|
|
|
13
|
+
| **`defineAgent` / workflow** | Declarative agent: `initial`, `states`, optional `id`, `description`, `specialization`, merged `tools` / `services`. |
|
|
14
|
+
| **`AgentMachine`** | Interprets the workflow: parked states wait for events; thinking states run `runTurn`; `transitions` picks the next state from context. |
|
|
15
|
+
| **`runTurn`** | Builds prompts, calls the LLM (with tools), parses the model output, and returns effects for the machine. |
|
|
16
|
+
| **`ZiggsAgent`** | Batteries-included process: OpenAI adapter, tool manager, task service, context read/write, **WebSocket** to Ziggs, and an **`Agent`** that dispatches incoming messages into the machine. |
|
|
17
|
+
| **`Agent`** | Orchestrates message handling, machine lifecycle, and integration with platform APIs (without requiring you to use WebSockets if you construct it yourself). |
|
|
18
|
+
|
|
19
|
+
There is **no** separate compile step: the workflow object is used **directly** by the runtime.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Workflow DSL (states)
|
|
24
|
+
|
|
25
|
+
States are plain objects on `workflow.states`. Every state uses one unified concept:
|
|
26
|
+
|
|
27
|
+
- **`transitions`** — an array of `{ to, when }` rules evaluated in order. The first rule whose `when(ctx)` returns true (or has no `when`) determines the next state. Supports a plain string shorthand: `'stateName'` is equivalent to `{ to: 'stateName' }`.
|
|
28
|
+
|
|
29
|
+
Two kinds of state:
|
|
30
|
+
|
|
31
|
+
- **Parked / waiting:** Has only `transitions`. The machine stops here and waits for the next event. When an event arrives it is classified into context flags (`approval`, `rejection`, `subtaskResult`, etc.) and `transitions` is evaluated to route forward.
|
|
32
|
+
|
|
33
|
+
- **Thinking:** Has `prompt`, `actions`, and `transitions`. The machine runs an LLM turn via `runTurn`, applies the result into context flags (`messageSent`, `toolResults`, `taskCompleted`, etc.), then evaluates `transitions` to route forward.
|
|
34
|
+
|
|
35
|
+
Both kinds use the same `transitions` array and the same context shape — the only difference is what filled the context before evaluation.
|
|
36
|
+
|
|
37
|
+
Context flags available in transitions:
|
|
38
|
+
|
|
39
|
+
| Set by incoming events (parked states) | Set by LLM turn results (thinking states) |
|
|
40
|
+
|---------------------------------------|------------------------------------------|
|
|
41
|
+
| `approval`, `rejection` | `messageSent`, `activeWait` |
|
|
42
|
+
| `taskAssignment`, `subtaskResult` | `proposal`, `delegatedTask` |
|
|
43
|
+
| `subtaskFailed`, `incomingMessage` | `taskCompleted`, `taskFailed` |
|
|
44
|
+
| | `toolResults`, `lastError`, `respondedProposal` |
|
|
45
|
+
|
|
46
|
+
The `wait` action is built-in: `defineAgent` automatically injects it into every thinking state that doesn't define one, and appends a `{ to: initial, when: ctx => ctx.activeWait }` transition if none exists.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## How you usually run an agent
|
|
51
|
+
|
|
52
|
+
1. **`defineAgent({ ... })`** → options object including `workflow`.
|
|
53
|
+
2. Pass **`openaiKey`**, **`operatorKey`** (Ziggs operator token), **`agentId`**, **`wsUrl`**, etc., and **`new ZiggsAgent(config)`** (or **`createAgent(config)`**).
|
|
54
|
+
3. **`connect()`** to open the WebSocket; the SDK routes platform messages into **`handleMessage`**.
|
|
55
|
+
|
|
56
|
+
Examples in the repo: `examples/agents/*.js` (e.g. coffee, expense, delivery agents).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Main exports (entry: `src/index.js`)
|
|
61
|
+
|
|
62
|
+
- **`ZiggsAgent`**, **`createAgent`**, **`defineAgent`**
|
|
63
|
+
- **`Agent`**, **`AgentMachine`**, **`runTurn`**
|
|
64
|
+
- **Prompt / tools:** `PromptBuilder`, `ToolManager`, `defineTool`
|
|
65
|
+
- **Task tools:** `taskMakeTaskTool`, `taskUpdateTaskTool`, `taskRespondProposalTool`, `taskMakeSubTasksTool`, `taskUpdatePlanStepTool`, `TASK_PROTOCOL_TOOLS`
|
|
66
|
+
- **Adapters / utils:** `OpenAIAdapter`, JSON helpers, formatters
|
|
67
|
+
- **Re-exported** from `@ziggs-ai/api-client`: `WebSocketClient`, `ContextReader`, `ContextWriter`, URL helpers, etc.
|
|
68
|
+
|
|
69
|
+
Package export: `"."` → `src/index.js` (see `package.json`).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Requirements
|
|
74
|
+
|
|
75
|
+
- **Node** ≥ 18
|
|
76
|
+
- Env: **`OPENAI_API_KEY`** (and optionally **`OPENAI_MODEL`**) for `defineAgent` defaults; Ziggs **`ZIGGS_OPERATOR_KEY`** (operator token, scope `agents:impersonate`) for platform features.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT (see package metadata).
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ziggs-ai/api-client": "^0.1.3",
|
|
15
|
+
"ajv": "^8.17.1",
|
|
16
|
+
"dotenv": "^17.2.3",
|
|
17
|
+
"openai": "^4.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["ziggs", "agent", "sdk", "ai", "autonomous"],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": ["src", "README.md"],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionPool — ZiggsAgent-aware wrapper around api-client's ConnectionManager.
|
|
3
|
+
*
|
|
4
|
+
* The actual pool mechanics (LRU, idle timeout, control socket) live in
|
|
5
|
+
* api-client and know nothing about agents. This class just registers each
|
|
6
|
+
* `defineAgent` config with the manager, supplying the open/close functions
|
|
7
|
+
* that instantiate and tear down a `ZiggsAgent`.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const pool = new ConnectionPool({ maxActive: 50, idleTimeoutMs: 60_000 });
|
|
11
|
+
* pool.register(agentConfigs, agentMetadata);
|
|
12
|
+
* pool.startControl({ wsUrl, operatorKey }); // optional, enables wake-on-demand
|
|
13
|
+
* const agent = await pool.wake('agent-42');
|
|
14
|
+
* await pool.sendTo('agent-42', text, metadata);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ConnectionManager } from '@ziggs-ai/api-client';
|
|
18
|
+
import { ZiggsAgent } from './ziggs/runtime.js';
|
|
19
|
+
|
|
20
|
+
export class ConnectionPool {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {number} [opts.maxActive=50]
|
|
24
|
+
* @param {number} [opts.idleTimeoutMs=60000]
|
|
25
|
+
*/
|
|
26
|
+
constructor({ maxActive = 50, idleTimeoutMs = 60_000 } = {}) {
|
|
27
|
+
this._mgr = new ConnectionManager({ maxActive, idleTimeoutMs });
|
|
28
|
+
// agentId → defineAgent config — kept for ZiggsAgent construction at wake time
|
|
29
|
+
this._configs = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- registration ----
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register agents without connecting them. Pool keys use `config.agentId`,
|
|
36
|
+
* falling back to `config.workflow?.id` / `config.id`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<object>} configs defineAgent-compatible configs
|
|
39
|
+
* @param {Array<object>} [metaArr] Parallel metadata objects keyed by the resolved agentId
|
|
40
|
+
*/
|
|
41
|
+
register(configs, metaArr = []) {
|
|
42
|
+
const metaByAgentId = Object.fromEntries(metaArr.map(m => [m.agentId, m]));
|
|
43
|
+
for (const config of configs) {
|
|
44
|
+
const agentId = config.agentId
|
|
45
|
+
?? config.workflow?.id
|
|
46
|
+
?? config.id;
|
|
47
|
+
if (!agentId) {
|
|
48
|
+
console.warn('[ConnectionPool] Skipping config with no resolvable agentId (need config.agentId)');
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
this._configs.set(agentId, config);
|
|
52
|
+
this._mgr.register(
|
|
53
|
+
agentId,
|
|
54
|
+
async () => {
|
|
55
|
+
const agent = new ZiggsAgent(this._configs.get(agentId));
|
|
56
|
+
await agent.connectAsync();
|
|
57
|
+
console.log(`[ConnectionPool] Woke "${agentId}" (active: ${this._mgr.listActive().length + 1}/${this._mgr.maxActive})`);
|
|
58
|
+
return agent;
|
|
59
|
+
},
|
|
60
|
+
(agent) => {
|
|
61
|
+
agent.disconnect();
|
|
62
|
+
console.log(`[ConnectionPool] Disconnected "${agentId}"`);
|
|
63
|
+
},
|
|
64
|
+
metaByAgentId[agentId],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- connection lifecycle ----
|
|
70
|
+
|
|
71
|
+
async wake(agentId) { return this._mgr.wake(agentId); }
|
|
72
|
+
async sleep(agentId) { return this._mgr.sleep(agentId); }
|
|
73
|
+
async disconnectAll() { return this._mgr.sleepAll(); }
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wake an agent, deliver a message, and return the agent's response text.
|
|
77
|
+
* Awaits the full LLM turn before returning. Resets the idle timer after delivery.
|
|
78
|
+
*/
|
|
79
|
+
async sendTo(agentId, text, metadata = {}, { timeoutMs = 30_000 } = {}) {
|
|
80
|
+
const agent = await this.wake(agentId);
|
|
81
|
+
const chatId = metadata.chatId;
|
|
82
|
+
|
|
83
|
+
const msgBuf = agent.agent?.messageBuffer?._pending;
|
|
84
|
+
const bufBefore = msgBuf?.get(chatId)?.length ?? 0;
|
|
85
|
+
|
|
86
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
87
|
+
setTimeout(() => reject(new Error(`sendTo timeout for "${agentId}" after ${timeoutMs}ms`)), timeoutMs)
|
|
88
|
+
);
|
|
89
|
+
await Promise.race([agent.handleMessage(text, metadata), timeoutPromise]);
|
|
90
|
+
this._mgr.touch(agentId);
|
|
91
|
+
|
|
92
|
+
const pending = msgBuf?.get(chatId);
|
|
93
|
+
if (pending && pending.length > bufBefore) {
|
|
94
|
+
const newEntries = pending.splice(bufBefore);
|
|
95
|
+
if (pending.length === 0) msgBuf.delete(chatId);
|
|
96
|
+
return newEntries.map(e => e.text).join('\n\n');
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- control socket ----
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Open the launcher's control socket to the backend. Agents in this pool
|
|
105
|
+
* will show as "available" in the store and the backend will push
|
|
106
|
+
* `launcher:wake` events for them as needed.
|
|
107
|
+
*/
|
|
108
|
+
startControl({ wsUrl, operatorKey } = {}) {
|
|
109
|
+
if (!wsUrl || !operatorKey) {
|
|
110
|
+
console.warn('[ConnectionPool] startControl: wsUrl and operatorKey are required — skipping');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this._mgr._controlOpts = { wsUrl, operatorKey };
|
|
114
|
+
this._mgr.start();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Close the control socket. Agents flip to "offline" on the backend immediately. */
|
|
118
|
+
stopControl() {
|
|
119
|
+
this._mgr._controlHandle?.close();
|
|
120
|
+
this._mgr._controlHandle = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- querying ----
|
|
124
|
+
|
|
125
|
+
query(filter) { return this._mgr.query(filter); }
|
|
126
|
+
list() { return this._mgr.list(); }
|
|
127
|
+
listActive() { return this._mgr.listActive(); }
|
|
128
|
+
get size() { return this._mgr.size; }
|
|
129
|
+
get maxActive() { return this._mgr.maxActive; }
|
|
130
|
+
|
|
131
|
+
// Legacy compatibility: p1000's list_agents tool reads _meta directly.
|
|
132
|
+
get _meta() { return { get: (id) => this._mgr.getMeta(id) }; }
|
|
133
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
|
|
3
|
+
export class OpenAIAdapter {
|
|
4
|
+
constructor({key, model = "gpt-4o-mini"}) {
|
|
5
|
+
this.client = new OpenAI({apiKey: key});
|
|
6
|
+
this.model = model;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Single-turn chat. `prompt` is a string; wraps it as a user message.
|
|
11
|
+
* Kept for backward compatibility.
|
|
12
|
+
*/
|
|
13
|
+
async chat(prompt, tools = [], temperature = 0.2, systemHints = [], options = {}) {
|
|
14
|
+
const messages = [
|
|
15
|
+
{ role: "system", content: "Respond with valid JSON." },
|
|
16
|
+
...systemHints,
|
|
17
|
+
{ role: "user", content: prompt }
|
|
18
|
+
];
|
|
19
|
+
return this.chatMessages(messages, tools, options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Low-level chat that accepts a full messages array.
|
|
24
|
+
* Returns { content, tool_calls, message } where message is the raw assistant message.
|
|
25
|
+
*/
|
|
26
|
+
async chatMessages(messages, tools = [], options = {}) {
|
|
27
|
+
const openAITools = tools.length > 0
|
|
28
|
+
? tools.map(t => {
|
|
29
|
+
const schema = t.schema || t;
|
|
30
|
+
const fn = schema.function || schema;
|
|
31
|
+
const usageWhen = t.usage?.when ? ` Use when: ${t.usage.when}` : '';
|
|
32
|
+
return {
|
|
33
|
+
type: 'function',
|
|
34
|
+
function: {
|
|
35
|
+
...fn,
|
|
36
|
+
description: (fn.description || '') + usageWhen
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
})
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const res = await this.client.chat.completions.create({
|
|
44
|
+
model: this.model,
|
|
45
|
+
messages,
|
|
46
|
+
max_completion_tokens: 3000,
|
|
47
|
+
response_format: openAITools ? undefined : (options.response_format ?? { type: 'json_object' }),
|
|
48
|
+
...(openAITools && { tools: openAITools }),
|
|
49
|
+
...options,
|
|
50
|
+
usage: undefined, // strip internal field if accidentally passed
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const msg = res.choices[0].message;
|
|
54
|
+
return {
|
|
55
|
+
content: msg.content?.trim() ?? null,
|
|
56
|
+
tool_calls: msg.tool_calls?.length ? msg.tool_calls : null,
|
|
57
|
+
message: msg,
|
|
58
|
+
usage: res.usage,
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error(`❌ [OpenAIAdapter] LLM API call failed: ${error.message}`, {
|
|
62
|
+
model: this.model,
|
|
63
|
+
error: error.message,
|
|
64
|
+
errorType: error.constructor?.name || 'Error',
|
|
65
|
+
status: error.status || error.statusCode || 'unknown',
|
|
66
|
+
code: error.code || 'unknown',
|
|
67
|
+
...(error.response && { response: error.response }),
|
|
68
|
+
stack: error.stack
|
|
69
|
+
});
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenAIAdapter } from './OpenAIAdapter.js';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { runTurn } from '../cognition/runTurn.js';
|
|
2
|
+
import { OutboxBuffer } from './OutboxBuffer.js';
|
|
3
|
+
import { EventQueue } from './EventQueue.js';
|
|
4
|
+
import { WorkflowRuntime } from '../workflow/WorkflowRuntime.js';
|
|
5
|
+
import { normalizeIncomingEvent } from '../ingress/normalizeIncoming.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Orchestrates one workflow instance per lane (ownAgentId:chatId).
|
|
9
|
+
* Each lane gets an AgentMachine; thinking states invoke `runTurn`.
|
|
10
|
+
*/
|
|
11
|
+
export class Agent {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const {
|
|
14
|
+
llm, toolManager, contextReader, contextWriter,
|
|
15
|
+
taskService, messageSender, promptBuilder, operatorKey, agentId,
|
|
16
|
+
} = options;
|
|
17
|
+
|
|
18
|
+
if (!llm) throw new Error('Agent: llm is required');
|
|
19
|
+
if (!operatorKey) throw new Error('Agent: operatorKey is required');
|
|
20
|
+
if (!agentId) throw new Error('Agent: agentId is required');
|
|
21
|
+
if (!contextReader) throw new Error('Agent: contextReader is required');
|
|
22
|
+
if (!promptBuilder) throw new Error('Agent: promptBuilder is required');
|
|
23
|
+
|
|
24
|
+
this.ownAgentId = agentId;
|
|
25
|
+
this.messageBuffer = new OutboxBuffer();
|
|
26
|
+
this._laneMeta = new Map();
|
|
27
|
+
this.definition = options.workflow;
|
|
28
|
+
if (!this.definition?.states) throw new Error('Agent: workflow definition with states is required');
|
|
29
|
+
|
|
30
|
+
this._services = {
|
|
31
|
+
llm, toolManager, contextReader, contextWriter,
|
|
32
|
+
taskService, messageSender, messageBuffer: this.messageBuffer,
|
|
33
|
+
promptBuilder, operatorKey, agentId: this.ownAgentId,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.runtime = new WorkflowRuntime({
|
|
37
|
+
definition: this.definition,
|
|
38
|
+
executionCore: runTurn,
|
|
39
|
+
services: this._services,
|
|
40
|
+
onSnapshot: (laneKey, snapshot) => this._onWorkflowChange(laneKey, snapshot),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async handleMessage(text, metadata = {}) {
|
|
47
|
+
const ownAgentId = this._resolveOwnAgentId(metadata);
|
|
48
|
+
const normalized = normalizeIncomingEvent({ text, metadata, ownAgentId });
|
|
49
|
+
if (!normalized.shouldProcess) {
|
|
50
|
+
console.log(`[Agent] Skip reason=${normalized.reason || 'not_relevant'} ownAgentId=${ownAgentId || 'unknown'}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const chatId = normalized.chatId;
|
|
54
|
+
if (!chatId) { console.warn('[Agent] No chatId, skipping'); return; }
|
|
55
|
+
|
|
56
|
+
const laneKey = this._buildLaneKey(ownAgentId, chatId);
|
|
57
|
+
this._laneMeta.set(laneKey, { chatId, ownAgentId });
|
|
58
|
+
return this.eventQueue.enqueue(normalized.event, laneKey);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async _processEvent(event, laneKey) {
|
|
62
|
+
const meta = this._laneMeta.get(laneKey) || this._parseLaneKey(laneKey);
|
|
63
|
+
const chatId = meta?.chatId || null;
|
|
64
|
+
const ownAgentId = meta?.ownAgentId || this.ownAgentId || null;
|
|
65
|
+
if (!chatId) return;
|
|
66
|
+
|
|
67
|
+
await this.runtime.sendAndWaitForPark({
|
|
68
|
+
laneKey,
|
|
69
|
+
event: { type: 'INCOMING_EVENT', event, chatId, laneKey, ownAgentId },
|
|
70
|
+
timeoutMs: 120_000,
|
|
71
|
+
machineInput: { chatId, laneKey, ownAgentId },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_onWorkflowChange(laneKey, snapshot) {
|
|
76
|
+
if (!snapshot.context) return;
|
|
77
|
+
const machine = this.runtime.actors.get(laneKey);
|
|
78
|
+
if (!machine) return;
|
|
79
|
+
|
|
80
|
+
const prev = machine._prevSnapshot;
|
|
81
|
+
machine._prevSnapshot = snapshot;
|
|
82
|
+
if (!prev) return;
|
|
83
|
+
|
|
84
|
+
const wasActive = !this._isParkedSnapshot(prev);
|
|
85
|
+
const nowParked = this._isParkedSnapshot(snapshot);
|
|
86
|
+
|
|
87
|
+
if (wasActive && nowParked) {
|
|
88
|
+
console.log(`[workflow] parked laneKey=${laneKey} state=${JSON.stringify(snapshot.value)}`);
|
|
89
|
+
this._trackMessages(snapshot);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_isParkedSnapshot(snapshot) {
|
|
94
|
+
const sd = this.definition.states[snapshot.value];
|
|
95
|
+
return !(sd?.prompt && sd?.actions);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_trackMessages(snapshot) {
|
|
99
|
+
const ctx = snapshot.context;
|
|
100
|
+
if (!ctx?.chatId || !ctx.messageSent) return;
|
|
101
|
+
const r = ctx.lastActionResult;
|
|
102
|
+
if (r?.type === 'message_sent') {
|
|
103
|
+
this.messageBuffer.track(r.chatId || ctx.chatId, { text: r.message, receiverId: r.receiverId });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_resolveOwnAgentId(metadata = {}) {
|
|
108
|
+
return this.ownAgentId || metadata.self?.id || metadata.agent?.id || metadata.ziggsAgentId || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_buildLaneKey(ownAgentId, chatId) {
|
|
112
|
+
return `${ownAgentId || 'unknown-agent'}:${chatId}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_parseLaneKey(laneKey) {
|
|
116
|
+
if (!laneKey || typeof laneKey !== 'string') return null;
|
|
117
|
+
const idx = laneKey.lastIndexOf(':');
|
|
118
|
+
if (idx === -1) return { ownAgentId: null, chatId: laneKey };
|
|
119
|
+
return { ownAgentId: laneKey.slice(0, idx) || null, chatId: laneKey.slice(idx + 1) || null };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializes and coalesces events per lane to prevent duplicate LLM turns.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class EventQueue {
|
|
6
|
+
constructor(processEventFn) {
|
|
7
|
+
this._processEvent = processEventFn;
|
|
8
|
+
/** @type {Map<string, { events: object[], processing: boolean }>} */
|
|
9
|
+
this._state = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
enqueue(event, laneKey) {
|
|
13
|
+
if (!laneKey) {
|
|
14
|
+
console.warn('[EventQueue] enqueue called without laneKey, skipping');
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let state = this._state.get(laneKey);
|
|
19
|
+
if (!state) {
|
|
20
|
+
state = { events: [], processing: false };
|
|
21
|
+
this._state.set(laneKey, state);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
state.events.push({ event, resolve });
|
|
26
|
+
console.log(`[EventQueue] enqueue laneKey=${laneKey}, queueLen=${state.events.length}, processing=${state.processing}`);
|
|
27
|
+
this._processIfNeeded(laneKey);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_processIfNeeded(laneKey) {
|
|
32
|
+
const state = this._state.get(laneKey);
|
|
33
|
+
if (!state || state.processing || state.events.length === 0) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
state.processing = true;
|
|
38
|
+
this._processLoop(laneKey);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async _processLoop(laneKey) {
|
|
42
|
+
const state = this._state.get(laneKey);
|
|
43
|
+
if (!state) return;
|
|
44
|
+
|
|
45
|
+
while (state.events.length > 0) {
|
|
46
|
+
const batch = state.events.splice(0, state.events.length);
|
|
47
|
+
const events = batch.map(e => e.event);
|
|
48
|
+
const resolvers = batch.map(e => e.resolve);
|
|
49
|
+
|
|
50
|
+
const coalesced = events.length === 1
|
|
51
|
+
? events[0]
|
|
52
|
+
: { type: 'batch', events };
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await this._processEvent(coalesced, laneKey);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`[EventQueue] processEvent error for laneKey=${laneKey}:`, error.message);
|
|
58
|
+
console.error(error.stack);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolve all waiters for this batch (success or failure)
|
|
62
|
+
for (const r of resolvers) r();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
state.processing = false;
|
|
66
|
+
console.log(`[EventQueue] processLoop done laneKey=${laneKey}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks outbound messages locally until server history syncs, then merges into read context.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class OutboxBuffer {
|
|
6
|
+
constructor() {
|
|
7
|
+
this._pending = new Map();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
track(chatId, { text, receiverId }) {
|
|
11
|
+
if (!this._pending.has(chatId)) {
|
|
12
|
+
this._pending.set(chatId, []);
|
|
13
|
+
}
|
|
14
|
+
this._pending.get(chatId).push({
|
|
15
|
+
text,
|
|
16
|
+
receiverId,
|
|
17
|
+
timestamp: Date.now()
|
|
18
|
+
});
|
|
19
|
+
console.log(`[OutboxBuffer] track: chatId=${chatId}, pending=${this._pending.get(chatId).length}, text="${text.slice(0, 60)}..."`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
merge(chatId, history, agentId) {
|
|
23
|
+
const pending = this._pending.get(chatId);
|
|
24
|
+
console.log(`[OutboxBuffer] merge: chatId=${chatId}, pending=${pending?.length || 0}, historyLen=${history.length}, agentId=${agentId}`);
|
|
25
|
+
if (!pending || pending.length === 0) return;
|
|
26
|
+
|
|
27
|
+
const matchedIndices = new Set();
|
|
28
|
+
const remaining = [];
|
|
29
|
+
|
|
30
|
+
for (const msg of pending) {
|
|
31
|
+
const matchIdx = history.findIndex((entry, idx) =>
|
|
32
|
+
!matchedIndices.has(idx) &&
|
|
33
|
+
entry.text === msg.text &&
|
|
34
|
+
entry.sender?.id === agentId
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (matchIdx !== -1) {
|
|
38
|
+
matchedIndices.add(matchIdx);
|
|
39
|
+
} else {
|
|
40
|
+
remaining.push(msg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const msg of remaining) {
|
|
45
|
+
history.push({
|
|
46
|
+
entryType: 'message',
|
|
47
|
+
text: msg.text,
|
|
48
|
+
sender: { id: agentId, type: 'agent' },
|
|
49
|
+
receiver: { id: msg.receiverId },
|
|
50
|
+
timestamp: msg.timestamp
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(`[OutboxBuffer] merge result: matched=${pending.length - remaining.length}, appended=${remaining.length}, newHistoryLen=${history.length}`);
|
|
55
|
+
|
|
56
|
+
if (remaining.length === 0) {
|
|
57
|
+
this._pending.delete(chatId);
|
|
58
|
+
} else {
|
|
59
|
+
this._pending.set(chatId, remaining);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|