@amalgm/agents 0.1.0
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/PURPOSE.md +45 -0
- package/README.md +110 -0
- package/dist/agent-store.d.ts +17 -0
- package/dist/agent-store.js +111 -0
- package/dist/agents.d.ts +39 -0
- package/dist/agents.js +129 -0
- package/dist/bin/agents.d.ts +2 -0
- package/dist/bin/agents.js +5 -0
- package/dist/bin/fatal.d.ts +1 -0
- package/dist/bin/fatal.js +5 -0
- package/dist/bin/mcp.d.ts +2 -0
- package/dist/bin/mcp.js +19 -0
- package/dist/bin/rest.d.ts +2 -0
- package/dist/bin/rest.js +21 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.js +23 -0
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +31 -0
- package/dist/cli/files.d.ts +2 -0
- package/dist/cli/files.js +21 -0
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +24 -0
- package/dist/cli/open.d.ts +3 -0
- package/dist/cli/open.js +12 -0
- package/dist/cli/run.d.ts +1 -0
- package/dist/cli/run.js +38 -0
- package/dist/cli/session-commands.d.ts +4 -0
- package/dist/cli/session-commands.js +54 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +2 -0
- package/dist/database.d.ts +9 -0
- package/dist/database.js +35 -0
- package/dist/definition.d.ts +4 -0
- package/dist/definition.js +93 -0
- package/dist/drivers.d.ts +4 -0
- package/dist/drivers.js +25 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +28 -0
- package/dist/event-store.d.ts +13 -0
- package/dist/event-store.js +50 -0
- package/dist/http/agent-routes.d.ts +2 -0
- package/dist/http/agent-routes.js +40 -0
- package/dist/http/request.d.ts +5 -0
- package/dist/http/request.js +40 -0
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.js +82 -0
- package/dist/http/session-routes.d.ts +2 -0
- package/dist/http/session-routes.js +67 -0
- package/dist/http/stream.d.ts +3 -0
- package/dist/http/stream.js +30 -0
- package/dist/http-types.d.ts +23 -0
- package/dist/http-types.js +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +1 -0
- package/dist/ids.d.ts +5 -0
- package/dist/ids.js +32 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.js +41 -0
- package/dist/mcp/agent-tools.d.ts +3 -0
- package/dist/mcp/agent-tools.js +51 -0
- package/dist/mcp/helpers.d.ts +5 -0
- package/dist/mcp/helpers.js +27 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +73 -0
- package/dist/mcp/session-tools.d.ts +3 -0
- package/dist/mcp/session-tools.js +81 -0
- package/dist/mcp/tools.d.ts +3 -0
- package/dist/mcp/tools.js +5 -0
- package/dist/mcp/types.d.ts +18 -0
- package/dist/mcp/types.js +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +2 -0
- package/dist/messages.d.ts +3 -0
- package/dist/messages.js +56 -0
- package/dist/rows.d.ts +7 -0
- package/dist/rows.js +65 -0
- package/dist/runtime.d.ts +29 -0
- package/dist/runtime.js +176 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +127 -0
- package/dist/session-store.d.ts +15 -0
- package/dist/session-store.js +83 -0
- package/dist/turn-store.d.ts +31 -0
- package/dist/turn-store.js +164 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.js +1 -0
- package/docs/ARCHITECTURE.md +73 -0
- package/docs/CLI.md +61 -0
- package/docs/DATA_MODEL.md +56 -0
- package/docs/DEFINITIONS.md +65 -0
- package/docs/DRIVERS.md +72 -0
- package/docs/ENGINE_INTEGRATION.md +74 -0
- package/docs/MCP.md +24 -0
- package/docs/REST.md +78 -0
- package/docs/SDK.md +72 -0
- package/docs/SECURITY.md +38 -0
- package/examples/basic.ts +31 -0
- package/examples/reviewer.json +25 -0
- package/package.json +64 -0
- package/skills/amalgm-agents/SKILL.md +30 -0
- package/skills/amalgm-agents/agents/openai.yaml +4 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { AgentError, asAgentError } from './errors.js';
|
|
2
|
+
import { assertByteLimit, isObject } from './json.js';
|
|
3
|
+
import { normalizeMessage } from './messages.js';
|
|
4
|
+
export class AgentRuntime {
|
|
5
|
+
agents;
|
|
6
|
+
sessions;
|
|
7
|
+
turns;
|
|
8
|
+
events;
|
|
9
|
+
limits;
|
|
10
|
+
drivers = new Map();
|
|
11
|
+
active = new Map();
|
|
12
|
+
constructor(agents, sessions, turns, events, limits, drivers = []) {
|
|
13
|
+
this.agents = agents;
|
|
14
|
+
this.sessions = sessions;
|
|
15
|
+
this.turns = turns;
|
|
16
|
+
this.events = events;
|
|
17
|
+
this.limits = limits;
|
|
18
|
+
for (const driver of drivers)
|
|
19
|
+
this.register(driver);
|
|
20
|
+
}
|
|
21
|
+
register(driver) {
|
|
22
|
+
if (!driver || typeof driver.id !== 'string' || typeof driver.run !== 'function') {
|
|
23
|
+
throw new AgentError('invalid_input', 'Agent drivers require id and run.');
|
|
24
|
+
}
|
|
25
|
+
if (this.drivers.has(driver.id)) {
|
|
26
|
+
throw new AgentError('conflict', `Agent driver already registered: ${driver.id}`);
|
|
27
|
+
}
|
|
28
|
+
this.drivers.set(driver.id, driver);
|
|
29
|
+
}
|
|
30
|
+
driverIds() {
|
|
31
|
+
return [...this.drivers.keys()].sort();
|
|
32
|
+
}
|
|
33
|
+
enqueue(sessionId, input) {
|
|
34
|
+
const message = normalizeMessage(input.message, 'user');
|
|
35
|
+
assertByteLimit(message, this.limits.maxInputBytes, 'message');
|
|
36
|
+
const reserved = this.turns.reserve(sessionId, message, input.idempotencyKey);
|
|
37
|
+
if (reserved.duplicate) {
|
|
38
|
+
const running = this.active.get(reserved.turn.id);
|
|
39
|
+
return {
|
|
40
|
+
turn: reserved.turn,
|
|
41
|
+
completion: running?.completion || Promise.resolve(reserved.turn),
|
|
42
|
+
duplicate: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const completion = this.execute(reserved.turn, controller);
|
|
47
|
+
this.active.set(reserved.turn.id, { controller, completion });
|
|
48
|
+
void completion.then(() => this.active.delete(reserved.turn.id), () => this.active.delete(reserved.turn.id));
|
|
49
|
+
return { turn: reserved.turn, completion, duplicate: false };
|
|
50
|
+
}
|
|
51
|
+
async send(sessionId, input) {
|
|
52
|
+
return this.enqueue(sessionId, input).completion;
|
|
53
|
+
}
|
|
54
|
+
cancel(sessionId) {
|
|
55
|
+
const turn = this.turns.active(sessionId);
|
|
56
|
+
if (!turn)
|
|
57
|
+
throw new AgentError('conflict', 'Session has no active turn.');
|
|
58
|
+
const execution = this.active.get(turn.id);
|
|
59
|
+
if (!execution)
|
|
60
|
+
throw new AgentError('conflict', 'Active turn is not owned by this process.');
|
|
61
|
+
const cancelling = this.turns.requestCancellation(turn.id);
|
|
62
|
+
execution.controller.abort(new AgentError('cancelled', 'Turn cancelled by caller.'));
|
|
63
|
+
return cancelling;
|
|
64
|
+
}
|
|
65
|
+
async shutdown() {
|
|
66
|
+
const executions = [...this.active.entries()];
|
|
67
|
+
for (const [turnId, execution] of executions) {
|
|
68
|
+
const turn = this.turns.get(turnId);
|
|
69
|
+
if (turn && (turn.status === 'queued' || turn.status === 'running')) {
|
|
70
|
+
this.turns.requestCancellation(turnId);
|
|
71
|
+
}
|
|
72
|
+
execution.controller.abort(new Error('Agents service shutting down.'));
|
|
73
|
+
}
|
|
74
|
+
await Promise.allSettled(executions.map(([, execution]) => execution.completion));
|
|
75
|
+
}
|
|
76
|
+
async execute(turn, controller) {
|
|
77
|
+
const session = this.sessions.require(turn.sessionId);
|
|
78
|
+
const revision = this.agents.revision(session.agentId, session.agentRevisionId);
|
|
79
|
+
const driver = this.drivers.get(revision.definition.driver.id);
|
|
80
|
+
if (!driver) {
|
|
81
|
+
return this.turns.fail(turn.id, failure('driver_unavailable', `Agent driver is not registered: ${revision.definition.driver.id}`));
|
|
82
|
+
}
|
|
83
|
+
this.turns.markRunning(turn.id);
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const timer = this.limits.turnTimeoutMs > 0
|
|
86
|
+
? setTimeout(() => {
|
|
87
|
+
timedOut = true;
|
|
88
|
+
controller.abort(new Error('Turn timed out.'));
|
|
89
|
+
}, this.limits.turnTimeoutMs)
|
|
90
|
+
: null;
|
|
91
|
+
timer?.unref();
|
|
92
|
+
try {
|
|
93
|
+
const result = await driver.run({
|
|
94
|
+
agent: revision,
|
|
95
|
+
session,
|
|
96
|
+
turn: this.turns.require(turn.id),
|
|
97
|
+
history: this.turns.history(session.id, turn.id),
|
|
98
|
+
input: turn.input,
|
|
99
|
+
driverSessionId: session.driverSessionId,
|
|
100
|
+
}, {
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
emit: (event) => this.emit(session.id, turn.id, event),
|
|
103
|
+
});
|
|
104
|
+
if (controller.signal.aborted) {
|
|
105
|
+
if (this.turns.require(turn.id).status === 'cancelling')
|
|
106
|
+
return this.turns.cancel(turn.id);
|
|
107
|
+
return this.turns.fail(turn.id, failure('turn_timeout', 'Turn timed out.'));
|
|
108
|
+
}
|
|
109
|
+
const normalized = this.normalizeResult(result);
|
|
110
|
+
if (normalized.message) {
|
|
111
|
+
const data = {
|
|
112
|
+
message: normalized.message,
|
|
113
|
+
};
|
|
114
|
+
assertByteLimit(data, this.limits.maxEventBytes, 'driver result message');
|
|
115
|
+
this.events.append(session.id, turn.id, 'message', data);
|
|
116
|
+
}
|
|
117
|
+
if (normalized.driverSessionId) {
|
|
118
|
+
this.sessions.setDriverSession(session.id, normalized.driverSessionId);
|
|
119
|
+
}
|
|
120
|
+
const storedResult = {
|
|
121
|
+
metadata: normalized.metadata || {},
|
|
122
|
+
...(normalized.driverSessionId ? { driverSessionId: normalized.driverSessionId } : {}),
|
|
123
|
+
};
|
|
124
|
+
assertByteLimit(storedResult, this.limits.maxEventBytes, 'driver result');
|
|
125
|
+
return this.turns.complete(turn.id, storedResult);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const current = this.turns.require(turn.id);
|
|
129
|
+
if (current.status === 'cancelling')
|
|
130
|
+
return this.turns.cancel(turn.id);
|
|
131
|
+
const normalized = asAgentError(error);
|
|
132
|
+
return this.turns.fail(turn.id, failure(timedOut ? 'turn_timeout' : normalized.code === 'internal' ? 'driver_error' : normalized.code, normalized.message, normalized.details));
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
if (timer)
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
emit(sessionId, turnId, event) {
|
|
140
|
+
if (!event || typeof event.type !== 'string' || !event.type.trim() || !isObject(event.data)) {
|
|
141
|
+
throw new AgentError('invalid_input', 'Driver events require type and object data.');
|
|
142
|
+
}
|
|
143
|
+
if (/^(session|turn)\./.test(event.type)) {
|
|
144
|
+
throw new AgentError('invalid_input', 'Driver events cannot use reserved session.* or turn.* types.');
|
|
145
|
+
}
|
|
146
|
+
assertByteLimit(event.data, this.limits.maxEventBytes, 'driver event');
|
|
147
|
+
if (event.type === 'message' && event.data.message) {
|
|
148
|
+
const message = normalizeMessage(event.data.message, 'assistant');
|
|
149
|
+
if (message.role !== 'assistant') {
|
|
150
|
+
throw new AgentError('invalid_input', 'Driver message events must have assistant role.');
|
|
151
|
+
}
|
|
152
|
+
return this.events.append(sessionId, turnId, 'message', { message: message });
|
|
153
|
+
}
|
|
154
|
+
return this.events.append(sessionId, turnId, event.type.trim(), event.data);
|
|
155
|
+
}
|
|
156
|
+
normalizeResult(result) {
|
|
157
|
+
if (!result)
|
|
158
|
+
return {};
|
|
159
|
+
const normalized = {};
|
|
160
|
+
if (result.message) {
|
|
161
|
+
normalized.message = normalizeMessage(result.message, 'assistant');
|
|
162
|
+
if (normalized.message.role !== 'assistant') {
|
|
163
|
+
throw new AgentError('invalid_input', 'Driver result messages must have assistant role.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (typeof result.driverSessionId === 'string' && result.driverSessionId.trim()) {
|
|
167
|
+
normalized.driverSessionId = result.driverSessionId.trim();
|
|
168
|
+
}
|
|
169
|
+
if (result.metadata && isObject(result.metadata))
|
|
170
|
+
normalized.metadata = result.metadata;
|
|
171
|
+
return normalized;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function failure(code, message, details = {}) {
|
|
175
|
+
return { code, message, details };
|
|
176
|
+
}
|
package/dist/schema.d.ts
ADDED
package/dist/schema.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const SCHEMA_VERSION = 2;
|
|
2
|
+
function migrateRevisionHashes(database) {
|
|
3
|
+
database.pragma('foreign_keys = OFF');
|
|
4
|
+
try {
|
|
5
|
+
database.exec(`
|
|
6
|
+
BEGIN IMMEDIATE;
|
|
7
|
+
|
|
8
|
+
CREATE TABLE agent_revisions_next (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
11
|
+
revision_number INTEGER NOT NULL,
|
|
12
|
+
definition_hash TEXT NOT NULL,
|
|
13
|
+
definition_json TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL,
|
|
15
|
+
UNIQUE(agent_id, revision_number)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
INSERT INTO agent_revisions_next
|
|
19
|
+
(id, agent_id, revision_number, definition_hash, definition_json, created_at)
|
|
20
|
+
SELECT id, agent_id, revision_number, definition_hash, definition_json, created_at
|
|
21
|
+
FROM agent_revisions;
|
|
22
|
+
|
|
23
|
+
DROP TABLE agent_revisions;
|
|
24
|
+
ALTER TABLE agent_revisions_next RENAME TO agent_revisions;
|
|
25
|
+
|
|
26
|
+
CREATE INDEX agent_revisions_agent_idx
|
|
27
|
+
ON agent_revisions(agent_id, revision_number DESC);
|
|
28
|
+
|
|
29
|
+
PRAGMA user_version = ${SCHEMA_VERSION};
|
|
30
|
+
COMMIT;
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (database.inTransaction)
|
|
35
|
+
database.exec('ROLLBACK');
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
database.pragma('foreign_keys = ON');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function migrate(database) {
|
|
43
|
+
const current = database.pragma('user_version', { simple: true });
|
|
44
|
+
if (current > SCHEMA_VERSION) {
|
|
45
|
+
throw new Error(`Agents database version ${current} is newer than supported version ${SCHEMA_VERSION}.`);
|
|
46
|
+
}
|
|
47
|
+
if (current === SCHEMA_VERSION)
|
|
48
|
+
return;
|
|
49
|
+
if (current === 1) {
|
|
50
|
+
migrateRevisionHashes(database);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
database.exec(`
|
|
54
|
+
CREATE TABLE agents (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
current_revision_id TEXT,
|
|
57
|
+
current_revision_number INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
created_at TEXT NOT NULL,
|
|
59
|
+
updated_at TEXT NOT NULL,
|
|
60
|
+
deleted_at TEXT
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE agent_revisions (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
66
|
+
revision_number INTEGER NOT NULL,
|
|
67
|
+
definition_hash TEXT NOT NULL,
|
|
68
|
+
definition_json TEXT NOT NULL,
|
|
69
|
+
created_at TEXT NOT NULL,
|
|
70
|
+
UNIQUE(agent_id, revision_number)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE sessions (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
agent_id TEXT NOT NULL REFERENCES agents(id),
|
|
76
|
+
agent_revision_id TEXT NOT NULL REFERENCES agent_revisions(id),
|
|
77
|
+
agent_revision_number INTEGER NOT NULL,
|
|
78
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
|
|
79
|
+
driver_session_id TEXT,
|
|
80
|
+
metadata_json TEXT NOT NULL,
|
|
81
|
+
created_at TEXT NOT NULL,
|
|
82
|
+
updated_at TEXT NOT NULL,
|
|
83
|
+
archived_at TEXT
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE turns (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
89
|
+
idempotency_key TEXT NOT NULL,
|
|
90
|
+
status TEXT NOT NULL CHECK(status IN (
|
|
91
|
+
'queued', 'running', 'cancelling', 'completed', 'failed', 'cancelled', 'interrupted'
|
|
92
|
+
)),
|
|
93
|
+
input_json TEXT NOT NULL,
|
|
94
|
+
result_json TEXT,
|
|
95
|
+
error_json TEXT,
|
|
96
|
+
created_at TEXT NOT NULL,
|
|
97
|
+
started_at TEXT,
|
|
98
|
+
completed_at TEXT,
|
|
99
|
+
UNIQUE(session_id, idempotency_key)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE session_events (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
105
|
+
turn_id TEXT REFERENCES turns(id),
|
|
106
|
+
sequence INTEGER NOT NULL,
|
|
107
|
+
type TEXT NOT NULL,
|
|
108
|
+
data_json TEXT NOT NULL,
|
|
109
|
+
created_at TEXT NOT NULL,
|
|
110
|
+
UNIQUE(session_id, sequence)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX agent_revisions_agent_idx
|
|
114
|
+
ON agent_revisions(agent_id, revision_number DESC);
|
|
115
|
+
CREATE INDEX sessions_agent_idx
|
|
116
|
+
ON sessions(agent_id, updated_at DESC);
|
|
117
|
+
CREATE INDEX turns_session_idx
|
|
118
|
+
ON turns(session_id, created_at ASC);
|
|
119
|
+
CREATE UNIQUE INDEX turns_one_active_per_session_idx
|
|
120
|
+
ON turns(session_id)
|
|
121
|
+
WHERE status IN ('queued', 'running', 'cancelling');
|
|
122
|
+
CREATE INDEX session_events_session_idx
|
|
123
|
+
ON session_events(session_id, sequence ASC);
|
|
124
|
+
|
|
125
|
+
PRAGMA user_version = ${SCHEMA_VERSION};
|
|
126
|
+
`);
|
|
127
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { AgentRevision, AgentSession, JsonObject } from './types.js';
|
|
3
|
+
import type { EventStore } from './event-store.js';
|
|
4
|
+
export declare class SessionStore {
|
|
5
|
+
private readonly database;
|
|
6
|
+
private readonly events;
|
|
7
|
+
private readonly now;
|
|
8
|
+
constructor(database: Database.Database, events: EventStore, now: () => string);
|
|
9
|
+
create(revision: AgentRevision, sessionId?: string, metadata?: JsonObject): AgentSession;
|
|
10
|
+
get(id: string): AgentSession | null;
|
|
11
|
+
require(id: string): AgentSession;
|
|
12
|
+
list(agentId?: string, includeArchived?: boolean): AgentSession[];
|
|
13
|
+
archive(id: string, hasActiveTurn: boolean): AgentSession;
|
|
14
|
+
setDriverSession(id: string, driverSessionId: string): void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AgentError } from './errors.js';
|
|
2
|
+
import { newId, optionalId, requireId } from './ids.js';
|
|
3
|
+
import { assertJsonObject } from './json.js';
|
|
4
|
+
import { sessionFromRow } from './rows.js';
|
|
5
|
+
export class SessionStore {
|
|
6
|
+
database;
|
|
7
|
+
events;
|
|
8
|
+
now;
|
|
9
|
+
constructor(database, events, now) {
|
|
10
|
+
this.database = database;
|
|
11
|
+
this.events = events;
|
|
12
|
+
this.now = now;
|
|
13
|
+
}
|
|
14
|
+
create(revision, sessionId, metadata = {}) {
|
|
15
|
+
const id = optionalId(sessionId, 'session id') || newId('session');
|
|
16
|
+
if (this.get(id))
|
|
17
|
+
throw new AgentError('conflict', `Session id already exists: ${id}`);
|
|
18
|
+
const timestamp = this.now();
|
|
19
|
+
const storedMetadata = assertJsonObject(metadata, 'metadata');
|
|
20
|
+
let event;
|
|
21
|
+
this.database.transaction(() => {
|
|
22
|
+
this.database.prepare(`
|
|
23
|
+
INSERT INTO sessions
|
|
24
|
+
(id, agent_id, agent_revision_id, agent_revision_number, status, metadata_json, created_at, updated_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
|
|
26
|
+
`).run(id, revision.agentId, revision.id, revision.number, JSON.stringify(storedMetadata), timestamp, timestamp);
|
|
27
|
+
event = this.events.write(id, null, 'session.started', {
|
|
28
|
+
agentId: revision.agentId,
|
|
29
|
+
agentRevisionId: revision.id,
|
|
30
|
+
agentRevision: revision.number,
|
|
31
|
+
});
|
|
32
|
+
})();
|
|
33
|
+
if (event)
|
|
34
|
+
this.events.announce(event);
|
|
35
|
+
return this.require(id);
|
|
36
|
+
}
|
|
37
|
+
get(id) {
|
|
38
|
+
const clean = requireId(id, 'session id');
|
|
39
|
+
const row = this.database.prepare('SELECT * FROM sessions WHERE id = ?').get(clean);
|
|
40
|
+
return row ? sessionFromRow(row) : null;
|
|
41
|
+
}
|
|
42
|
+
require(id) {
|
|
43
|
+
const session = this.get(id);
|
|
44
|
+
if (!session)
|
|
45
|
+
throw new AgentError('not_found', `Session not found: ${id}`);
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
list(agentId, includeArchived = false) {
|
|
49
|
+
const filters = [];
|
|
50
|
+
const values = [];
|
|
51
|
+
if (agentId) {
|
|
52
|
+
filters.push('agent_id = ?');
|
|
53
|
+
values.push(requireId(agentId, 'agent id'));
|
|
54
|
+
}
|
|
55
|
+
if (!includeArchived)
|
|
56
|
+
filters.push("status = 'active'");
|
|
57
|
+
const where = filters.length ? ` WHERE ${filters.join(' AND ')}` : '';
|
|
58
|
+
return this.database.prepare(`SELECT * FROM sessions${where} ORDER BY updated_at DESC`).all(...values)
|
|
59
|
+
.map(sessionFromRow);
|
|
60
|
+
}
|
|
61
|
+
archive(id, hasActiveTurn) {
|
|
62
|
+
const session = this.require(id);
|
|
63
|
+
if (hasActiveTurn)
|
|
64
|
+
throw new AgentError('conflict', 'Cannot archive a session with an active turn.');
|
|
65
|
+
if (session.status === 'archived')
|
|
66
|
+
return session;
|
|
67
|
+
const timestamp = this.now();
|
|
68
|
+
let event;
|
|
69
|
+
this.database.transaction(() => {
|
|
70
|
+
this.database.prepare(`
|
|
71
|
+
UPDATE sessions SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?
|
|
72
|
+
`).run(timestamp, timestamp, session.id);
|
|
73
|
+
event = this.events.write(session.id, null, 'session.archived', {});
|
|
74
|
+
})();
|
|
75
|
+
if (event)
|
|
76
|
+
this.events.announce(event);
|
|
77
|
+
return this.require(session.id);
|
|
78
|
+
}
|
|
79
|
+
setDriverSession(id, driverSessionId) {
|
|
80
|
+
this.database.prepare('UPDATE sessions SET driver_session_id = ?, updated_at = ? WHERE id = ?')
|
|
81
|
+
.run(driverSessionId, this.now(), requireId(id, 'session id'));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { EventStore } from './event-store.js';
|
|
3
|
+
import type { SessionStore } from './session-store.js';
|
|
4
|
+
import type { AgentFailure, AgentMessage, AgentTurn, JsonObject } from './types.js';
|
|
5
|
+
export declare class TurnStore {
|
|
6
|
+
private readonly database;
|
|
7
|
+
private readonly sessions;
|
|
8
|
+
private readonly events;
|
|
9
|
+
private readonly now;
|
|
10
|
+
constructor(database: Database.Database, sessions: SessionStore, events: EventStore, now: () => string);
|
|
11
|
+
reserve(sessionId: string, input: AgentMessage, key?: string): {
|
|
12
|
+
turn: AgentTurn;
|
|
13
|
+
duplicate: boolean;
|
|
14
|
+
};
|
|
15
|
+
markRunning(id: string): AgentTurn;
|
|
16
|
+
requestCancellation(id: string): AgentTurn;
|
|
17
|
+
complete(id: string, result: JsonObject): AgentTurn;
|
|
18
|
+
fail(id: string, failure: AgentFailure): AgentTurn;
|
|
19
|
+
cancel(id: string): AgentTurn;
|
|
20
|
+
interrupt(id: string, failure: AgentFailure): AgentTurn;
|
|
21
|
+
get(id: string): AgentTurn | null;
|
|
22
|
+
require(id: string): AgentTurn;
|
|
23
|
+
list(sessionId: string): AgentTurn[];
|
|
24
|
+
active(sessionId: string): AgentTurn | null;
|
|
25
|
+
history(sessionId: string, beforeTurnId?: string): AgentMessage[];
|
|
26
|
+
recoverInterrupted(): number;
|
|
27
|
+
private byKey;
|
|
28
|
+
private transition;
|
|
29
|
+
private terminal;
|
|
30
|
+
private touch;
|
|
31
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AgentError } from './errors.js';
|
|
2
|
+
import { newId, optionalKey, randomKey, requireId } from './ids.js';
|
|
3
|
+
import { isObject } from './json.js';
|
|
4
|
+
import { turnFromRow } from './rows.js';
|
|
5
|
+
const ACTIVE_SQL = `status IN ('queued', 'running', 'cancelling')`;
|
|
6
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'interrupted']);
|
|
7
|
+
export class TurnStore {
|
|
8
|
+
database;
|
|
9
|
+
sessions;
|
|
10
|
+
events;
|
|
11
|
+
now;
|
|
12
|
+
constructor(database, sessions, events, now) {
|
|
13
|
+
this.database = database;
|
|
14
|
+
this.sessions = sessions;
|
|
15
|
+
this.events = events;
|
|
16
|
+
this.now = now;
|
|
17
|
+
}
|
|
18
|
+
reserve(sessionId, input, key) {
|
|
19
|
+
const session = this.sessions.require(sessionId);
|
|
20
|
+
if (session.status !== 'active')
|
|
21
|
+
throw new AgentError('conflict', 'Session is archived.');
|
|
22
|
+
const idempotencyKey = optionalKey(key) || randomKey('turn-key');
|
|
23
|
+
const existing = this.byKey(session.id, idempotencyKey);
|
|
24
|
+
if (existing)
|
|
25
|
+
return { turn: existing, duplicate: true };
|
|
26
|
+
if (this.active(session.id))
|
|
27
|
+
throw new AgentError('conflict', 'Session already has an active turn.');
|
|
28
|
+
const id = newId('turn');
|
|
29
|
+
const timestamp = this.now();
|
|
30
|
+
let event;
|
|
31
|
+
try {
|
|
32
|
+
this.database.transaction(() => {
|
|
33
|
+
this.database.prepare(`
|
|
34
|
+
INSERT INTO turns (id, session_id, idempotency_key, status, input_json, created_at)
|
|
35
|
+
VALUES (?, ?, ?, 'queued', ?, ?)
|
|
36
|
+
`).run(id, session.id, idempotencyKey, JSON.stringify(input), timestamp);
|
|
37
|
+
event = this.events.write(session.id, id, 'turn.input', { message: input });
|
|
38
|
+
this.touch(session.id, timestamp);
|
|
39
|
+
})();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const racedDuplicate = this.byKey(session.id, idempotencyKey);
|
|
43
|
+
if (racedDuplicate)
|
|
44
|
+
return { turn: racedDuplicate, duplicate: true };
|
|
45
|
+
if (isUniqueConstraint(error)) {
|
|
46
|
+
throw new AgentError('conflict', 'Session already has an active turn.');
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (event)
|
|
51
|
+
this.events.announce(event);
|
|
52
|
+
return { turn: this.require(id), duplicate: false };
|
|
53
|
+
}
|
|
54
|
+
markRunning(id) {
|
|
55
|
+
return this.transition(id, 'running', 'turn.running', true);
|
|
56
|
+
}
|
|
57
|
+
requestCancellation(id) {
|
|
58
|
+
return this.transition(id, 'cancelling', 'turn.cancelling');
|
|
59
|
+
}
|
|
60
|
+
complete(id, result) {
|
|
61
|
+
return this.terminal(id, 'completed', result, null, 'turn.completed');
|
|
62
|
+
}
|
|
63
|
+
fail(id, failure) {
|
|
64
|
+
return this.terminal(id, 'failed', null, failure, 'turn.failed');
|
|
65
|
+
}
|
|
66
|
+
cancel(id) {
|
|
67
|
+
return this.terminal(id, 'cancelled', null, null, 'turn.cancelled');
|
|
68
|
+
}
|
|
69
|
+
interrupt(id, failure) {
|
|
70
|
+
return this.terminal(id, 'interrupted', null, failure, 'turn.interrupted');
|
|
71
|
+
}
|
|
72
|
+
get(id) {
|
|
73
|
+
const row = this.database.prepare('SELECT * FROM turns WHERE id = ?').get(requireId(id, 'turn id'));
|
|
74
|
+
return row ? turnFromRow(row) : null;
|
|
75
|
+
}
|
|
76
|
+
require(id) {
|
|
77
|
+
const turn = this.get(id);
|
|
78
|
+
if (!turn)
|
|
79
|
+
throw new AgentError('not_found', `Turn not found: ${id}`);
|
|
80
|
+
return turn;
|
|
81
|
+
}
|
|
82
|
+
list(sessionId) {
|
|
83
|
+
this.sessions.require(sessionId);
|
|
84
|
+
return this.database.prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY created_at ASC')
|
|
85
|
+
.all(sessionId).map(turnFromRow);
|
|
86
|
+
}
|
|
87
|
+
active(sessionId) {
|
|
88
|
+
const row = this.database.prepare(`
|
|
89
|
+
SELECT * FROM turns WHERE session_id = ? AND ${ACTIVE_SQL} ORDER BY created_at DESC LIMIT 1
|
|
90
|
+
`).get(sessionId);
|
|
91
|
+
return row ? turnFromRow(row) : null;
|
|
92
|
+
}
|
|
93
|
+
history(sessionId, beforeTurnId) {
|
|
94
|
+
const messages = [];
|
|
95
|
+
for (const turn of this.list(sessionId)) {
|
|
96
|
+
if (turn.id === beforeTurnId)
|
|
97
|
+
break;
|
|
98
|
+
messages.push(turn.input);
|
|
99
|
+
for (const event of this.events.forTurn(turn.id)) {
|
|
100
|
+
const message = isObject(event.data.message) ? event.data.message : null;
|
|
101
|
+
if (event.type === 'message' && message)
|
|
102
|
+
messages.push(message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return messages;
|
|
106
|
+
}
|
|
107
|
+
recoverInterrupted() {
|
|
108
|
+
const rows = this.database.prepare(`SELECT * FROM turns WHERE ${ACTIVE_SQL}`).all();
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
this.interrupt(turnFromRow(row).id, {
|
|
111
|
+
code: 'process_restarted',
|
|
112
|
+
message: 'The owning process restarted before the driver settled.',
|
|
113
|
+
details: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return rows.length;
|
|
117
|
+
}
|
|
118
|
+
byKey(sessionId, key) {
|
|
119
|
+
const row = this.database.prepare('SELECT * FROM turns WHERE session_id = ? AND idempotency_key = ?')
|
|
120
|
+
.get(sessionId, key);
|
|
121
|
+
return row ? turnFromRow(row) : null;
|
|
122
|
+
}
|
|
123
|
+
transition(id, status, type, started = false) {
|
|
124
|
+
const turn = this.require(id);
|
|
125
|
+
const timestamp = this.now();
|
|
126
|
+
let event;
|
|
127
|
+
this.database.transaction(() => {
|
|
128
|
+
this.database.prepare(`UPDATE turns SET status = ?, started_at = COALESCE(started_at, ?) WHERE id = ?`)
|
|
129
|
+
.run(status, started ? timestamp : turn.startedAt, turn.id);
|
|
130
|
+
event = this.events.write(turn.sessionId, turn.id, type, {});
|
|
131
|
+
this.touch(turn.sessionId, timestamp);
|
|
132
|
+
})();
|
|
133
|
+
if (event)
|
|
134
|
+
this.events.announce(event);
|
|
135
|
+
return this.require(turn.id);
|
|
136
|
+
}
|
|
137
|
+
terminal(id, status, result, error, type) {
|
|
138
|
+
const turn = this.require(id);
|
|
139
|
+
if (TERMINAL.has(turn.status))
|
|
140
|
+
return turn;
|
|
141
|
+
const timestamp = this.now();
|
|
142
|
+
let event;
|
|
143
|
+
this.database.transaction(() => {
|
|
144
|
+
this.database.prepare(`
|
|
145
|
+
UPDATE turns SET status = ?, result_json = ?, error_json = ?, completed_at = ? WHERE id = ?
|
|
146
|
+
`).run(status, result && JSON.stringify(result), error && JSON.stringify(error), timestamp, turn.id);
|
|
147
|
+
const data = error ? { error: error } : {};
|
|
148
|
+
event = this.events.write(turn.sessionId, turn.id, type, data);
|
|
149
|
+
this.touch(turn.sessionId, timestamp);
|
|
150
|
+
})();
|
|
151
|
+
if (event)
|
|
152
|
+
this.events.announce(event);
|
|
153
|
+
return this.require(turn.id);
|
|
154
|
+
}
|
|
155
|
+
touch(sessionId, timestamp) {
|
|
156
|
+
this.database.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(timestamp, sessionId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function isUniqueConstraint(error) {
|
|
160
|
+
return Boolean(error
|
|
161
|
+
&& typeof error === 'object'
|
|
162
|
+
&& 'code' in error
|
|
163
|
+
&& String(error.code).startsWith('SQLITE_CONSTRAINT'));
|
|
164
|
+
}
|