@ziggs-ai/agent-sdk 0.1.3 → 0.1.4
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 +1 -1
- package/package.json +9 -4
- package/src/AgentHost.ts +342 -0
- package/src/adapters/OpenAIAdapter.ts +125 -0
- package/src/agent/Agent.ts +98 -0
- package/src/cognition/validateContext.ts +95 -0
- package/src/context/applyEffects.ts +80 -0
- package/src/context/batch.ts +17 -0
- package/src/context/classifyEnvelope.ts +38 -0
- package/src/context/routingLabels.ts +46 -0
- package/src/defineAgent.ts +62 -0
- package/src/formatters/AgreementFormatter.ts +111 -0
- package/src/formatters/HistoryFormatter.ts +166 -0
- package/src/formatters/index.ts +2 -0
- package/src/index.ts +86 -0
- package/src/ingress/normalizeIncoming.ts +119 -0
- package/src/memory/MemoryStore.ts +104 -0
- package/src/runtime/AgentMachine.ts +298 -0
- package/src/runtime/PromptBuilder.ts +461 -0
- package/src/runtime/buildOutcome.ts +488 -0
- package/src/runtime/defaults.ts +72 -0
- package/src/runtime/runTurn.ts +637 -0
- package/src/runtime/validateWorkflow.ts +165 -0
- package/src/server/ConnectionPool.ts +155 -0
- package/src/server/EventQueue.ts +119 -0
- package/src/server/OutboxBuffer.ts +90 -0
- package/src/server/ZiggsEffectHandler.ts +335 -0
- package/src/server/agreements/AgreementService.ts +111 -0
- package/src/server/createHealthServer.ts +8 -0
- package/src/server/proactive/ProactiveTrigger.ts +83 -0
- package/src/server/runLauncher.ts +131 -0
- package/src/server/tasks/TaskService.ts +111 -0
- package/src/server/tasks/index.ts +4 -0
- package/src/server/tasks/paymentTools.ts +156 -0
- package/src/server/tasks/protocolRunner.ts +101 -0
- package/src/server/tasks/protocolTools.ts +96 -0
- package/src/server/ziggspay/ZiggsPayClient.ts +193 -0
- package/src/shared/ids.ts +3 -0
- package/src/shared/runtimeLog.ts +72 -0
- package/src/shared/types.ts +31 -0
- package/src/tasks/protocolRegistry.ts +25 -0
- package/src/tasks/taskCore.ts +139 -0
- package/src/tools/ToolManager.ts +95 -0
- package/src/tools/{ToolProvider.js → ToolProvider.ts} +5 -15
- package/src/tools/defineTool.ts +90 -0
- package/src/tools/index.ts +5 -0
- package/src/types.ts +368 -0
- package/src/utils/jsonExtractor.ts +100 -0
- package/src/ConnectionPool.js +0 -133
- package/src/adapters/OpenAIAdapter.js +0 -73
- package/src/agent/Agent.js +0 -121
- package/src/agent/EventQueue.js +0 -68
- package/src/agent/OutboxBuffer.js +0 -62
- package/src/cognition/PromptBuilder.js +0 -312
- package/src/cognition/resolveActionTool.js +0 -12
- package/src/cognition/runTurn.js +0 -578
- package/src/context/applyEffects.js +0 -133
- package/src/context/batch.js +0 -25
- package/src/context/classifyEnvelope.js +0 -82
- package/src/context/routingLabels.js +0 -54
- package/src/createHealthServer.js +0 -28
- package/src/formatters/HistoryFormatter.js +0 -257
- package/src/formatters/TaskFormatter.js +0 -180
- package/src/formatters/index.js +0 -9
- package/src/index.js +0 -76
- package/src/ingress/normalizeIncoming.js +0 -70
- package/src/runLauncher.js +0 -159
- package/src/shared/ids.js +0 -7
- package/src/shared/types.js +0 -86
- package/src/tasks/TaskService.js +0 -247
- package/src/tasks/index.js +0 -9
- package/src/tasks/taskCore.js +0 -229
- package/src/tasks/taskProtocolRegistry.js +0 -22
- package/src/tasks/taskProtocolRunner.js +0 -107
- package/src/tasks/taskProtocolTools.js +0 -87
- package/src/tools/ToolManager.js +0 -79
- package/src/tools/defineTool.js +0 -82
- package/src/tools/index.js +0 -11
- package/src/utils/jsonExtractor.js +0 -139
- package/src/workflow/AgentMachine.js +0 -250
- package/src/workflow/WorkflowRuntime.js +0 -63
- package/src/workflow/dsl.js +0 -287
- package/src/workflow/motifs.js +0 -435
- package/src/ziggs/runtime.js +0 -192
- /package/src/adapters/{index.js → index.ts} +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Ctx, OutcomeKind, Workflow, ThinkingState } from '../types.js';
|
|
2
|
+
|
|
3
|
+
const PROBE_CTX: Ctx = {
|
|
4
|
+
identity: { agentId: '', sessionId: '', laneKey: '' },
|
|
5
|
+
activeAgreementId: null,
|
|
6
|
+
activeTaskId: null,
|
|
7
|
+
pendingProposalAgreementId: null,
|
|
8
|
+
delegatedAgreementIds: [],
|
|
9
|
+
delegatedTaskIds: [],
|
|
10
|
+
toolResults: [],
|
|
11
|
+
lastOutcome: { kind: 'enter' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const KNOWN_OUTCOME_KINDS = new Set<OutcomeKind>([
|
|
15
|
+
'enter',
|
|
16
|
+
'wait',
|
|
17
|
+
'timeout',
|
|
18
|
+
'message-sent',
|
|
19
|
+
'message-received',
|
|
20
|
+
'task-assigned',
|
|
21
|
+
'task-delegated',
|
|
22
|
+
'task-closed',
|
|
23
|
+
'subtask-finished',
|
|
24
|
+
'proposal-made',
|
|
25
|
+
'proposal-resolved',
|
|
26
|
+
'tool-result',
|
|
27
|
+
'error',
|
|
28
|
+
'extension',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export class WorkflowValidationError extends Error {
|
|
32
|
+
readonly issues: string[];
|
|
33
|
+
constructor(issues: string[]) {
|
|
34
|
+
super(`Workflow validation failed:\n - ${issues.join('\n - ')}`);
|
|
35
|
+
this.name = 'WorkflowValidationError';
|
|
36
|
+
this.issues = issues;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ValidationResult {
|
|
41
|
+
errors: string[];
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Synchronous structural validation. Errors throw; warnings are returned for
|
|
47
|
+
* the caller to log. defineAgent runs this on construction, so misconfigured
|
|
48
|
+
* workflows fail loudly at load instead of mysteriously misbehaving at runtime.
|
|
49
|
+
*/
|
|
50
|
+
export function validateWorkflow(workflow: Workflow): ValidationResult {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
const warnings: string[] = [];
|
|
53
|
+
|
|
54
|
+
if (!workflow || typeof workflow !== 'object') {
|
|
55
|
+
throw new WorkflowValidationError(['workflow must be an object']);
|
|
56
|
+
}
|
|
57
|
+
if (!workflow.id) errors.push('workflow.id is required');
|
|
58
|
+
if (!workflow.initial) errors.push('workflow.initial is required');
|
|
59
|
+
if (!workflow.states || typeof workflow.states !== 'object') {
|
|
60
|
+
errors.push('workflow.states is required');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (errors.length) throw new WorkflowValidationError(errors);
|
|
64
|
+
|
|
65
|
+
const stateNames = new Set(Object.keys(workflow.states));
|
|
66
|
+
if (!stateNames.has(workflow.initial)) {
|
|
67
|
+
errors.push(`workflow.initial "${workflow.initial}" is not a defined state`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [name, state] of Object.entries(workflow.states)) {
|
|
71
|
+
if (!state || typeof state !== 'object') {
|
|
72
|
+
errors.push(`state "${name}" is not an object`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (state.kind !== 'parked' && state.kind !== 'thinking') {
|
|
76
|
+
errors.push(`state "${name}" must declare kind: 'parked' | 'thinking'`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Shape rejects mixing
|
|
81
|
+
if (state.kind === 'parked') {
|
|
82
|
+
if ((state as unknown as ThinkingState).prompt) {
|
|
83
|
+
errors.push(`state "${name}" is parked but has a prompt — parked states are event-driven only`);
|
|
84
|
+
}
|
|
85
|
+
if ((state as unknown as ThinkingState).actions) {
|
|
86
|
+
errors.push(`state "${name}" is parked but has actions — parked states are event-driven only`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (state.kind === 'thinking') {
|
|
90
|
+
if (!state.prompt) errors.push(`thinking state "${name}" requires a prompt`);
|
|
91
|
+
if (!state.actions || Object.keys(state.actions).length === 0) {
|
|
92
|
+
errors.push(`thinking state "${name}" requires at least one action`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!Array.isArray(state.transitions)) {
|
|
97
|
+
errors.push(`state "${name}" requires a transitions array`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < state.transitions.length; i++) {
|
|
102
|
+
const t = state.transitions[i];
|
|
103
|
+
if (!t || typeof t !== 'object') {
|
|
104
|
+
errors.push(`state "${name}" transition[${i}] is not an object`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (typeof t.to !== 'string') {
|
|
108
|
+
errors.push(`state "${name}" transition[${i}] is missing string "to"`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!stateNames.has(t.to)) {
|
|
112
|
+
errors.push(`state "${name}" transition[${i}] points to unknown state "${t.to}"`);
|
|
113
|
+
}
|
|
114
|
+
if (t.when !== undefined && typeof t.when !== 'function') {
|
|
115
|
+
errors.push(`state "${name}" transition[${i}].when must be a function or omitted`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (state.kind === 'thinking') {
|
|
120
|
+
for (const [actionName, action] of Object.entries(state.actions)) {
|
|
121
|
+
if (!action || typeof action !== 'object') {
|
|
122
|
+
errors.push(`state "${name}" action "${actionName}" is not an object`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!action.prompt || !action.prompt.instruction) {
|
|
126
|
+
errors.push(`state "${name}" action "${actionName}" requires prompt.instruction`);
|
|
127
|
+
}
|
|
128
|
+
const p = action.produces;
|
|
129
|
+
if (typeof p === 'string') {
|
|
130
|
+
if (!KNOWN_OUTCOME_KINDS.has(p as OutcomeKind)) {
|
|
131
|
+
errors.push(
|
|
132
|
+
`state "${name}" action "${actionName}" produces unknown outcome kind "${p}" — use one of: ${[...KNOWN_OUTCOME_KINDS].join(', ')}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} else if (typeof p !== 'function') {
|
|
136
|
+
errors.push(`state "${name}" action "${actionName}" requires produces (OutcomeKind or fn)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Soft check: thinking state should have a wait action and a fallthrough.
|
|
141
|
+
// Authors can opt out via allowNoWait when intentionally building a
|
|
142
|
+
// looping or always-progressing state.
|
|
143
|
+
if (!state.allowNoWait) {
|
|
144
|
+
if (!state.actions?.wait) {
|
|
145
|
+
warnings.push(
|
|
146
|
+
`thinking state "${name}" has no "wait" action — did you forget to spread thinkingDefaults({ initial: '${workflow.initial}' })? Set allowNoWait: true to silence.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const hasWaitFallthrough = state.transitions.some(t => {
|
|
150
|
+
if (!t.when) return true;
|
|
151
|
+
try { return t.when({ kind: 'wait' }, PROBE_CTX); }
|
|
152
|
+
catch { return false; }
|
|
153
|
+
});
|
|
154
|
+
if (!hasWaitFallthrough) {
|
|
155
|
+
warnings.push(
|
|
156
|
+
`thinking state "${name}" has no fallthrough for { kind: 'wait' } — agent may stall when LLM picks wait. Spread thinkingDefaults().transitions or add explicitly.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (errors.length) throw new WorkflowValidationError(errors);
|
|
164
|
+
return { errors, warnings };
|
|
165
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionPool — multi-agent fleet manager.
|
|
3
|
+
*
|
|
4
|
+
* Holds N `AgentHostOptions` configs but only constructs/wakes an
|
|
5
|
+
* `AgentHost` (one WS connection) when needed. Idle hosts are slept after
|
|
6
|
+
* `idleTimeoutMs`. At most `maxActive` hosts are connected at a time; LRU
|
|
7
|
+
* eviction drops the oldest if the cap is reached. With `startControl`, a
|
|
8
|
+
* separate "launcher" socket receives backend-driven wake hints so the pool
|
|
9
|
+
* spins up the right host on demand.
|
|
10
|
+
*
|
|
11
|
+
* This is how a single Node process serves a fleet of agents without
|
|
12
|
+
* holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
|
|
13
|
+
*/
|
|
14
|
+
import { ConnectionManager } from '@ziggs-ai/api-client';
|
|
15
|
+
import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
|
|
16
|
+
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
17
|
+
|
|
18
|
+
// Inlined from api-client (not re-exported there). Sync if the upstream
|
|
19
|
+
// types change.
|
|
20
|
+
interface ConnectionManagerMeta {
|
|
21
|
+
domain?: string;
|
|
22
|
+
expertise?: string[];
|
|
23
|
+
tags?: string[];
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface QueryFilter {
|
|
28
|
+
domain?: string;
|
|
29
|
+
expertise?: string[];
|
|
30
|
+
tags?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PoolOptions {
|
|
34
|
+
maxActive?: number;
|
|
35
|
+
idleTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SendToOptions {
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ControlOptions {
|
|
43
|
+
wsUrl?: string;
|
|
44
|
+
operatorKey?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ConnectionPool {
|
|
48
|
+
private readonly manager: ConnectionManager;
|
|
49
|
+
private readonly configs = new Map<string, AgentHostOptions>();
|
|
50
|
+
|
|
51
|
+
constructor({ maxActive = 50, idleTimeoutMs = 60_000 }: PoolOptions = {}) {
|
|
52
|
+
this.manager = new ConnectionManager({ maxActive, idleTimeoutMs });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register N agent configs. Each will be lazily woken (a fresh
|
|
57
|
+
* `AgentHost` constructed + connected) the first time it's referenced.
|
|
58
|
+
*/
|
|
59
|
+
register(configs: AgentHostOptions[], metaArr: ConnectionManagerMeta[] = []): void {
|
|
60
|
+
const metaByAgentId = new Map<string, ConnectionManagerMeta>();
|
|
61
|
+
for (const m of metaArr) {
|
|
62
|
+
const id = m['agentId'] as string | undefined;
|
|
63
|
+
if (id) metaByAgentId.set(id, m);
|
|
64
|
+
}
|
|
65
|
+
for (const config of configs) {
|
|
66
|
+
const agentId = config.agentId ?? config.workflow?.id;
|
|
67
|
+
if (!agentId) {
|
|
68
|
+
runtimeLog.warn('ConnectionPool', 'skipping config with no resolvable agentId');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this.configs.set(agentId, config);
|
|
72
|
+
this.manager.register(
|
|
73
|
+
agentId,
|
|
74
|
+
async () => {
|
|
75
|
+
const host = new AgentHost(this.configs.get(agentId)!);
|
|
76
|
+
await host.connectAsync();
|
|
77
|
+
runtimeLog.debug(
|
|
78
|
+
'ConnectionPool',
|
|
79
|
+
`woke "${agentId}" (active: ${this.manager.listActive().length}/${this.maxActive})`,
|
|
80
|
+
);
|
|
81
|
+
return host;
|
|
82
|
+
},
|
|
83
|
+
async (host) => {
|
|
84
|
+
(host as AgentHost).disconnect();
|
|
85
|
+
runtimeLog.debug('ConnectionPool', `slept "${agentId}"`);
|
|
86
|
+
},
|
|
87
|
+
metaByAgentId.get(agentId),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async wake(agentId: string): Promise<AgentHost> {
|
|
93
|
+
return await this.manager.wake(agentId) as AgentHost;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async sleep(agentId: string): Promise<void> {
|
|
97
|
+
return this.manager.sleep(agentId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async disconnectAll(): Promise<void> {
|
|
101
|
+
return this.manager.sleepAll();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Wake the named agent and deliver one wire frame to it. Returns once the
|
|
106
|
+
* host has finished processing.
|
|
107
|
+
*
|
|
108
|
+
* Note: does not return the agent's response. Capturing responses
|
|
109
|
+
* synchronously required internal-buffer poking the Agent/Server split
|
|
110
|
+
* removed. For request/response semantics, run the agent under an
|
|
111
|
+
* AgentHost with an injected EffectHandler that records sends.
|
|
112
|
+
*/
|
|
113
|
+
async sendTo(agentId: string, text: string, metadata: Record<string, unknown> = {}, { timeoutMs = 30_000 }: SendToOptions = {}): Promise<null> {
|
|
114
|
+
const host = await this.wake(agentId);
|
|
115
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
116
|
+
setTimeout(() => reject(new Error(`sendTo timeout for "${agentId}" after ${timeoutMs}ms`)), timeoutMs),
|
|
117
|
+
);
|
|
118
|
+
await Promise.race([host.handleMessage(text, metadata), timeoutPromise]);
|
|
119
|
+
this.manager.touch(agentId);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Open a launcher (control) socket that the backend uses to push wake
|
|
125
|
+
* hints for any of the registered agents. Pool spins up the right host
|
|
126
|
+
* lazily when a hint arrives.
|
|
127
|
+
*/
|
|
128
|
+
startControl({ wsUrl, operatorKey }: ControlOptions = {}): void {
|
|
129
|
+
if (!wsUrl || !operatorKey) {
|
|
130
|
+
runtimeLog.warn(
|
|
131
|
+
'ConnectionPool',
|
|
132
|
+
'startControl: wsUrl and operatorKey are required — skipping',
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// ConnectionManager only accepts `control` at construction; mutating its
|
|
137
|
+
// private field is the smallest way to add it later. (Cleaner fix lives
|
|
138
|
+
// in api-client: expose a `setControl` setter.)
|
|
139
|
+
(this.manager as unknown as Record<string, unknown>)['_controlOpts'] = { wsUrl, operatorKey };
|
|
140
|
+
this.manager.start();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
stopControl(): void {
|
|
144
|
+
const m = this.manager as unknown as Record<string, unknown>;
|
|
145
|
+
(m['_controlHandle'] as { close?: () => void } | null)?.close?.();
|
|
146
|
+
m['_controlHandle'] = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
query(filter: QueryFilter): string[] { return this.manager.query(filter); }
|
|
150
|
+
list(): string[] { return this.manager.list(); }
|
|
151
|
+
listActive(): string[] { return this.manager.listActive(); }
|
|
152
|
+
get size(): number { return this.manager.size; }
|
|
153
|
+
get maxActive(): number { return (this.manager as unknown as { maxActive: number }).maxActive; }
|
|
154
|
+
getMeta(id: string): ConnectionManagerMeta | undefined { return this.manager.getMeta(id); }
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
2
|
+
|
|
3
|
+
export type ProcessEventFn = (event: unknown, laneKey: string) => Promise<void>;
|
|
4
|
+
|
|
5
|
+
interface QueuedEntry {
|
|
6
|
+
event: unknown;
|
|
7
|
+
resolve: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface LaneState {
|
|
11
|
+
events: QueuedEntry[];
|
|
12
|
+
processing: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface QueueMetrics {
|
|
16
|
+
enqueued: number;
|
|
17
|
+
dropped: number;
|
|
18
|
+
processedBatches: number;
|
|
19
|
+
processedEvents: number;
|
|
20
|
+
errors: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EventQueueOptions {
|
|
24
|
+
maxQueueLengthPerLane?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Serializes events per lane and coalesces multiple queued events into one
|
|
29
|
+
* batch dispatch. The runtime sees at most one in-flight processEvent call
|
|
30
|
+
* per laneKey at a time; multiple events arriving while busy are merged
|
|
31
|
+
* into a single { type: 'batch', events: [...] } frame on the next pass.
|
|
32
|
+
*/
|
|
33
|
+
export class EventQueue {
|
|
34
|
+
private _processEvent: ProcessEventFn;
|
|
35
|
+
private _maxQueueLengthPerLane: number;
|
|
36
|
+
private _state: Map<string, LaneState> = new Map();
|
|
37
|
+
private _metrics: QueueMetrics = {
|
|
38
|
+
enqueued: 0, dropped: 0, processedBatches: 0, processedEvents: 0, errors: 0,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
constructor(processEventFn: ProcessEventFn, options: EventQueueOptions = {}) {
|
|
42
|
+
this._processEvent = processEventFn;
|
|
43
|
+
this._maxQueueLengthPerLane = Number.isFinite(options.maxQueueLengthPerLane)
|
|
44
|
+
? Math.max(1, options.maxQueueLengthPerLane!)
|
|
45
|
+
: 100;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
enqueue(event: unknown, laneKey: string): Promise<void> {
|
|
49
|
+
if (!laneKey) {
|
|
50
|
+
runtimeLog.warn('EventQueue', 'enqueue called without laneKey, skipping');
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let state = this._state.get(laneKey);
|
|
55
|
+
if (!state) {
|
|
56
|
+
state = { events: [], processing: false };
|
|
57
|
+
this._state.set(laneKey, state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Promise<void>((resolve) => {
|
|
61
|
+
this._metrics.enqueued += 1;
|
|
62
|
+
state!.events.push({ event, resolve });
|
|
63
|
+
this._trimQueueIfNeeded(state!, laneKey);
|
|
64
|
+
this._processIfNeeded(laneKey);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private _processIfNeeded(laneKey: string): void {
|
|
69
|
+
const state = this._state.get(laneKey);
|
|
70
|
+
if (!state || state.processing || state.events.length === 0) return;
|
|
71
|
+
state.processing = true;
|
|
72
|
+
this._processLoop(laneKey);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async _processLoop(laneKey: string): Promise<void> {
|
|
76
|
+
const state = this._state.get(laneKey);
|
|
77
|
+
if (!state) return;
|
|
78
|
+
|
|
79
|
+
while (state.events.length > 0) {
|
|
80
|
+
const batch = state.events.splice(0, state.events.length);
|
|
81
|
+
const events = batch.map(e => e.event);
|
|
82
|
+
const resolvers = batch.map(e => e.resolve);
|
|
83
|
+
|
|
84
|
+
const coalesced = events.length === 1
|
|
85
|
+
? events[0]
|
|
86
|
+
: { type: 'batch', events };
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await this._processEvent(coalesced, laneKey);
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
this._metrics.errors += 1;
|
|
92
|
+
runtimeLog.error('EventQueue', `processEvent error for laneKey=${laneKey}: ${(error as Error)?.message || error}`);
|
|
93
|
+
}
|
|
94
|
+
this._metrics.processedBatches += 1;
|
|
95
|
+
this._metrics.processedEvents += events.length;
|
|
96
|
+
|
|
97
|
+
for (const r of resolvers) r();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
state.processing = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private _trimQueueIfNeeded(state: LaneState, laneKey: string): void {
|
|
104
|
+
while (state.events.length > this._maxQueueLengthPerLane) {
|
|
105
|
+
const dropped = state.events.shift();
|
|
106
|
+
this._metrics.dropped += 1;
|
|
107
|
+
dropped?.resolve();
|
|
108
|
+
runtimeLog.warn('EventQueue', `dropped oldest event laneKey=${laneKey} max=${this._maxQueueLengthPerLane}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getMetrics() {
|
|
113
|
+
return {
|
|
114
|
+
...this._metrics,
|
|
115
|
+
lanes: this._state.size,
|
|
116
|
+
queuedEvents: [...this._state.values()].reduce((sum, lane) => sum + lane.events.length, 0),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
2
|
+
|
|
3
|
+
interface PendingMessage {
|
|
4
|
+
text: string;
|
|
5
|
+
receiverId: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OutboxBufferOptions {
|
|
10
|
+
ttlMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HistoryEntryShape {
|
|
14
|
+
entryType?: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
sender?: { id?: string; type?: string };
|
|
17
|
+
receiver?: { id?: string };
|
|
18
|
+
timestamp?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tracks outbound messages locally until server history syncs them, then
|
|
23
|
+
* merges any unmatched ones into the next read context. Prevents the LLM
|
|
24
|
+
* from re-saying things it already said when the server is slow to publish.
|
|
25
|
+
*/
|
|
26
|
+
export class OutboxBuffer {
|
|
27
|
+
private _pending: Map<string, PendingMessage[]> = new Map();
|
|
28
|
+
private _ttlMs: number;
|
|
29
|
+
|
|
30
|
+
constructor(options: OutboxBufferOptions = {}) {
|
|
31
|
+
this._ttlMs = Number.isFinite(options.ttlMs) ? Math.max(1_000, options.ttlMs!) : 10 * 60 * 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
track(chatId: string, msg: { text: string; receiverId: string }): void {
|
|
35
|
+
this._gcChat(chatId);
|
|
36
|
+
if (!this._pending.has(chatId)) this._pending.set(chatId, []);
|
|
37
|
+
this._pending.get(chatId)!.push({ text: msg.text, receiverId: msg.receiverId, timestamp: Date.now() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
merge(chatId: string, history: HistoryEntryShape[], agentId: string | null): void {
|
|
41
|
+
this._gcChat(chatId);
|
|
42
|
+
const pending = this._pending.get(chatId);
|
|
43
|
+
if (!pending || pending.length === 0) return;
|
|
44
|
+
|
|
45
|
+
const matchedIndices = new Set<number>();
|
|
46
|
+
const remaining: PendingMessage[] = [];
|
|
47
|
+
|
|
48
|
+
for (const msg of pending) {
|
|
49
|
+
const matchIdx = history.findIndex((entry, idx) =>
|
|
50
|
+
!matchedIndices.has(idx) &&
|
|
51
|
+
entry.text === msg.text &&
|
|
52
|
+
entry.sender?.id === agentId,
|
|
53
|
+
);
|
|
54
|
+
if (matchIdx !== -1) matchedIndices.add(matchIdx);
|
|
55
|
+
else remaining.push(msg);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const msg of remaining) {
|
|
59
|
+
history.push({
|
|
60
|
+
entryType: 'message',
|
|
61
|
+
text: msg.text,
|
|
62
|
+
sender: { id: agentId || undefined, type: 'agent' },
|
|
63
|
+
receiver: { id: msg.receiverId },
|
|
64
|
+
timestamp: msg.timestamp,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (remaining.length === 0) this._pending.delete(chatId);
|
|
69
|
+
else this._pending.set(chatId, remaining);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
gc(): void {
|
|
73
|
+
for (const chatId of this._pending.keys()) this._gcChat(chatId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private _gcChat(chatId: string): void {
|
|
77
|
+
const pending = this._pending.get(chatId);
|
|
78
|
+
if (!pending?.length) return;
|
|
79
|
+
const cutoff = Date.now() - this._ttlMs;
|
|
80
|
+
const fresh = pending.filter(msg => msg.timestamp >= cutoff);
|
|
81
|
+
if (fresh.length === 0) {
|
|
82
|
+
this._pending.delete(chatId);
|
|
83
|
+
runtimeLog.warn('OutboxBuffer', `expired all pending messages chatId=${chatId}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (fresh.length !== pending.length) {
|
|
87
|
+
this._pending.set(chatId, fresh);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|