@hamak/ai-agent-backend 0.7.1
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/agent-routes.d.ts +46 -0
- package/dist/agent-routes.d.ts.map +1 -0
- package/dist/agent-routes.js +93 -0
- package/dist/anthropic-provider.d.ts +41 -0
- package/dist/anthropic-provider.d.ts.map +1 -0
- package/dist/anthropic-provider.js +162 -0
- package/dist/create-agent-backend.d.ts +35 -0
- package/dist/create-agent-backend.d.ts.map +1 -0
- package/dist/create-agent-backend.js +39 -0
- package/dist/file-agent-memory.d.ts +25 -0
- package/dist/file-agent-memory.d.ts.map +1 -0
- package/dist/file-agent-memory.js +124 -0
- package/dist/hooks-bus.d.ts +12 -0
- package/dist/hooks-bus.d.ts.map +1 -0
- package/dist/hooks-bus.js +29 -0
- package/dist/http-server-channel.d.ts +22 -0
- package/dist/http-server-channel.d.ts.map +1 -0
- package/dist/http-server-channel.js +38 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/stdio-mcp.d.ts +45 -0
- package/dist/stdio-mcp.d.ts.map +1 -0
- package/dist/stdio-mcp.js +136 -0
- package/package.json +51 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express route factory for the agent (ticket §10 PR2). Mounts a small REST +
|
|
3
|
+
* SSE surface backed by a server-side `AgentService` (hosted provider + durable
|
|
4
|
+
* memory). Mirrors the `event-channel-backend` route/SSE conventions.
|
|
5
|
+
*
|
|
6
|
+
* POST /chat → SSE: `token` deltas, then `turn-end`
|
|
7
|
+
* GET /conversations?agent= → ConversationSummary[]
|
|
8
|
+
* DELETE /conversations/:id?agent= → { ok: true }
|
|
9
|
+
*
|
|
10
|
+
* `express` is an optional peer; this module is only imported by hosts that
|
|
11
|
+
* have it. Types are structural so it doesn't hard-import express.
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentService } from '@hamak/ai-agent';
|
|
14
|
+
import type { HooksBus } from './hooks-bus.js';
|
|
15
|
+
import type { HttpServerChannel } from './http-server-channel.js';
|
|
16
|
+
interface Req {
|
|
17
|
+
body: any;
|
|
18
|
+
query: Record<string, unknown>;
|
|
19
|
+
params: Record<string, string>;
|
|
20
|
+
on(event: 'close', cb: () => void): void;
|
|
21
|
+
}
|
|
22
|
+
interface Res {
|
|
23
|
+
setHeader(name: string, value: string): void;
|
|
24
|
+
write(chunk: string): void;
|
|
25
|
+
end(): void;
|
|
26
|
+
status(code: number): Res;
|
|
27
|
+
json(body: unknown): void;
|
|
28
|
+
flushHeaders?(): void;
|
|
29
|
+
}
|
|
30
|
+
interface RouterLike {
|
|
31
|
+
post(path: string, handler: (req: Req, res: Res) => void): void;
|
|
32
|
+
get(path: string, handler: (req: Req, res: Res) => void): void;
|
|
33
|
+
delete(path: string, handler: (req: Req, res: Res) => void): void;
|
|
34
|
+
}
|
|
35
|
+
export interface AgentRoutesConfig {
|
|
36
|
+
service: AgentService;
|
|
37
|
+
/** The same bus the service emits on; the SSE handler relays `ai:token`. */
|
|
38
|
+
hooks: HooksBus;
|
|
39
|
+
/** Factory for a fresh router, e.g. `() => express.Router()`. */
|
|
40
|
+
router: () => RouterLike;
|
|
41
|
+
/** Split-loop channel (§4): mounts GET/POST `/channel` when provided. */
|
|
42
|
+
channel?: HttpServerChannel;
|
|
43
|
+
}
|
|
44
|
+
export declare function createAgentRoutes(config: AgentRoutesConfig): RouterLike;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=agent-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-routes.d.ts","sourceRoot":"","sources":["../src/agent-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D,UAAU,GAAG;IACX,IAAI,EAAE,GAAG,CAAC;IACV,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CAC1C;AACD,UAAU,GAAG;IACX,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,IAAI,IAAI,CAAC;IACZ,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC;IAC1B,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1B,YAAY,CAAC,IAAI,IAAI,CAAC;CACvB;AACD,UAAU,UAAU;IAClB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,IAAI,CAAC;IAChE,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,IAAI,CAAC;CACnE;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,YAAY,CAAC;IACtB,4EAA4E;IAC5E,KAAK,EAAE,QAAQ,CAAC;IAChB,iEAAiE;IACjE,MAAM,EAAE,MAAM,UAAU,CAAC;IACzB,yEAAyE;IACzE,OAAO,CAAC,EAAE,iBAAiB,CAAC;CAC7B;AAWD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB,GAAG,UAAU,CAoFvE"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express route factory for the agent (ticket §10 PR2). Mounts a small REST +
|
|
3
|
+
* SSE surface backed by a server-side `AgentService` (hosted provider + durable
|
|
4
|
+
* memory). Mirrors the `event-channel-backend` route/SSE conventions.
|
|
5
|
+
*
|
|
6
|
+
* POST /chat → SSE: `token` deltas, then `turn-end`
|
|
7
|
+
* GET /conversations?agent= → ConversationSummary[]
|
|
8
|
+
* DELETE /conversations/:id?agent= → { ok: true }
|
|
9
|
+
*
|
|
10
|
+
* `express` is an optional peer; this module is only imported by hosts that
|
|
11
|
+
* have it. Types are structural so it doesn't hard-import express.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
function sse(res, event, data) {
|
|
15
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
16
|
+
}
|
|
17
|
+
function agentName(req) {
|
|
18
|
+
const a = req.query.agent;
|
|
19
|
+
return typeof a === 'string' ? a : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function createAgentRoutes(config) {
|
|
22
|
+
const { service, hooks } = config;
|
|
23
|
+
const router = config.router();
|
|
24
|
+
router.post('/chat', (req, res) => {
|
|
25
|
+
const { input, sessionId, agent } = (req.body ?? {});
|
|
26
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
27
|
+
res.status(400).json({ error: 'input (string) is required' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Pin the session up front so we can scope the token stream to this turn.
|
|
31
|
+
const sid = sessionId ?? randomUUID();
|
|
32
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
33
|
+
res.setHeader('cache-control', 'no-cache');
|
|
34
|
+
res.setHeader('connection', 'keep-alive');
|
|
35
|
+
res.flushHeaders?.();
|
|
36
|
+
sse(res, 'session', { sessionId: sid });
|
|
37
|
+
const onToken = (payload) => {
|
|
38
|
+
if (payload?.sessionId === sid) {
|
|
39
|
+
sse(res, 'token', payload);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
hooks.on('ai:token', onToken);
|
|
43
|
+
let done = false;
|
|
44
|
+
const cleanup = () => {
|
|
45
|
+
if (done)
|
|
46
|
+
return;
|
|
47
|
+
done = true;
|
|
48
|
+
hooks.off('ai:token', onToken);
|
|
49
|
+
};
|
|
50
|
+
req.on('close', cleanup);
|
|
51
|
+
service
|
|
52
|
+
.askStream(agent, input, { sessionId: sid })
|
|
53
|
+
.then((result) => {
|
|
54
|
+
sse(res, 'turn-end', result);
|
|
55
|
+
cleanup();
|
|
56
|
+
res.end();
|
|
57
|
+
})
|
|
58
|
+
.catch((err) => {
|
|
59
|
+
sse(res, 'error', { error: String(err) });
|
|
60
|
+
cleanup();
|
|
61
|
+
res.end();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
router.get('/conversations', (req, res) => {
|
|
65
|
+
service
|
|
66
|
+
.listConversations(agentName(req))
|
|
67
|
+
.then((list) => res.json(list))
|
|
68
|
+
.catch((err) => res.status(500).json({ error: String(err) }));
|
|
69
|
+
});
|
|
70
|
+
router.delete('/conversations/:id', (req, res) => {
|
|
71
|
+
service
|
|
72
|
+
.reset(agentName(req), req.params.id)
|
|
73
|
+
.then(() => res.json({ ok: true }))
|
|
74
|
+
.catch((err) => res.status(500).json({ error: String(err) }));
|
|
75
|
+
});
|
|
76
|
+
// Split-loop duplex channel (§4): SSE down + POST up.
|
|
77
|
+
const channel = config.channel;
|
|
78
|
+
if (channel) {
|
|
79
|
+
router.get('/channel', (req, res) => {
|
|
80
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
81
|
+
res.setHeader('cache-control', 'no-cache');
|
|
82
|
+
res.setHeader('connection', 'keep-alive');
|
|
83
|
+
res.flushHeaders?.();
|
|
84
|
+
channel.addClient(res);
|
|
85
|
+
req.on('close', () => channel.removeClient(res));
|
|
86
|
+
});
|
|
87
|
+
router.post('/channel', (req, res) => {
|
|
88
|
+
channel.ingest(req.body);
|
|
89
|
+
res.json({ ok: true });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return router;
|
|
93
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic provider — hosted Claude models via the Messages API (ticket §3,
|
|
3
|
+
* topology B: runs backend-side so the API key never reaches a browser).
|
|
4
|
+
*
|
|
5
|
+
* Uses `fetch` directly to match the sibling `OllamaProvider` and to avoid
|
|
6
|
+
* adding an SDK dependency the workspace doesn't otherwise carry; swapping to
|
|
7
|
+
* `@anthropic-ai/sdk` later is a drop-in behind this same `IAgentProvider`.
|
|
8
|
+
*
|
|
9
|
+
* Wire format (POST https://api.anthropic.com/v1/messages):
|
|
10
|
+
* headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
|
|
11
|
+
* body: { model, max_tokens, system?, messages, stream }
|
|
12
|
+
* stream: SSE — message_start (usage.input_tokens),
|
|
13
|
+
* content_block_delta/text_delta (delta.text),
|
|
14
|
+
* message_delta (usage.output_tokens), message_stop.
|
|
15
|
+
*
|
|
16
|
+
* Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
|
|
17
|
+
* sent only when explicitly configured. Default model: claude-opus-4-8.
|
|
18
|
+
*/
|
|
19
|
+
import type { AgentEvent, AgentMessage, AgentResult, CompleteOpts, IAgentProvider } from '@hamak/ai-agent';
|
|
20
|
+
export interface AnthropicProviderConfig {
|
|
21
|
+
/** API key; falls back to `process.env.ANTHROPIC_API_KEY`. */
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
/** Override the base URL (proxies, gateways). */
|
|
24
|
+
baseURL?: string;
|
|
25
|
+
/** Override the provider id (default `anthropic`). */
|
|
26
|
+
id?: string;
|
|
27
|
+
/** Custom fetch (tests inject this); defaults to global `fetch`. */
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
export declare class AnthropicProvider implements IAgentProvider {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
private readonly apiKey;
|
|
33
|
+
private readonly url;
|
|
34
|
+
private readonly fetchImpl;
|
|
35
|
+
constructor(config?: AnthropicProviderConfig);
|
|
36
|
+
private headers;
|
|
37
|
+
private body;
|
|
38
|
+
complete(messages: AgentMessage[], opts: CompleteOpts): Promise<AgentResult>;
|
|
39
|
+
stream(messages: AgentMessage[], opts: CompleteOpts): AsyncIterable<AgentEvent>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=anthropic-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anthropic-provider.d.ts","sourceRoot":"","sources":["../src/anthropic-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,cAAc,EACf,MAAM,iBAAiB,CAAC;AAMzB,MAAM,WAAW,uBAAuB;IACtC,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA4BD,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,MAAM,GAAE,uBAA4B;IAahD,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,IAAI;IAYN,QAAQ,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IAgC3E,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,UAAU,CAAC;CA+CvF"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic provider — hosted Claude models via the Messages API (ticket §3,
|
|
3
|
+
* topology B: runs backend-side so the API key never reaches a browser).
|
|
4
|
+
*
|
|
5
|
+
* Uses `fetch` directly to match the sibling `OllamaProvider` and to avoid
|
|
6
|
+
* adding an SDK dependency the workspace doesn't otherwise carry; swapping to
|
|
7
|
+
* `@anthropic-ai/sdk` later is a drop-in behind this same `IAgentProvider`.
|
|
8
|
+
*
|
|
9
|
+
* Wire format (POST https://api.anthropic.com/v1/messages):
|
|
10
|
+
* headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
|
|
11
|
+
* body: { model, max_tokens, system?, messages, stream }
|
|
12
|
+
* stream: SSE — message_start (usage.input_tokens),
|
|
13
|
+
* content_block_delta/text_delta (delta.text),
|
|
14
|
+
* message_delta (usage.output_tokens), message_stop.
|
|
15
|
+
*
|
|
16
|
+
* Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
|
|
17
|
+
* sent only when explicitly configured. Default model: claude-opus-4-8.
|
|
18
|
+
*/
|
|
19
|
+
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
|
20
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
21
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
22
|
+
/** Map agent messages to Anthropic's user/assistant turns. */
|
|
23
|
+
function toAnthropicMessages(messages) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const m of messages) {
|
|
26
|
+
if (m.role === 'system')
|
|
27
|
+
continue; // system is passed separately
|
|
28
|
+
// tool results are surfaced as plain user text in this text-only skeleton
|
|
29
|
+
out.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content });
|
|
30
|
+
}
|
|
31
|
+
// The Messages API requires the first turn to be `user`.
|
|
32
|
+
if (out.length > 0 && out[0].role !== 'user') {
|
|
33
|
+
out.unshift({ role: 'user', content: '(context)' });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
let seq = 0;
|
|
38
|
+
function genId() {
|
|
39
|
+
seq += 1;
|
|
40
|
+
return `msg-anthropic-${seq}`;
|
|
41
|
+
}
|
|
42
|
+
export class AnthropicProvider {
|
|
43
|
+
constructor(config = {}) {
|
|
44
|
+
this.id = config.id ?? 'anthropic';
|
|
45
|
+
this.apiKey = config.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '';
|
|
46
|
+
this.url = config.baseURL ?? ANTHROPIC_URL;
|
|
47
|
+
const resolved = config.fetchImpl ??
|
|
48
|
+
(typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined);
|
|
49
|
+
if (!resolved) {
|
|
50
|
+
throw new Error('[anthropic] no fetch implementation available');
|
|
51
|
+
}
|
|
52
|
+
this.fetchImpl = resolved;
|
|
53
|
+
}
|
|
54
|
+
headers() {
|
|
55
|
+
if (!this.apiKey) {
|
|
56
|
+
throw new Error('[anthropic] missing API key (set ANTHROPIC_API_KEY or pass apiKey)');
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
'x-api-key': this.apiKey,
|
|
61
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
body(messages, opts, stream) {
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
model: opts.model,
|
|
67
|
+
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
68
|
+
...(opts.systemPrompt ? { system: opts.systemPrompt } : {}),
|
|
69
|
+
// Only send temperature when explicitly set — Opus 4.8/4.7 reject it.
|
|
70
|
+
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
|
|
71
|
+
messages: toAnthropicMessages(messages),
|
|
72
|
+
stream,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async complete(messages, opts) {
|
|
76
|
+
const res = await this.fetchImpl(this.url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: this.headers(),
|
|
79
|
+
body: this.body(messages, opts, false),
|
|
80
|
+
signal: opts.signal,
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`[anthropic] ${res.status} ${res.statusText}: ${await safeText(res)}`);
|
|
84
|
+
}
|
|
85
|
+
const json = (await res.json());
|
|
86
|
+
const content = (json.content ?? [])
|
|
87
|
+
.filter((b) => b.type === 'text')
|
|
88
|
+
.map((b) => b.text ?? '')
|
|
89
|
+
.join('');
|
|
90
|
+
return {
|
|
91
|
+
message: {
|
|
92
|
+
id: genId(),
|
|
93
|
+
role: 'assistant',
|
|
94
|
+
content,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
},
|
|
97
|
+
usage: {
|
|
98
|
+
inputTokens: json.usage?.input_tokens ?? 0,
|
|
99
|
+
outputTokens: json.usage?.output_tokens ?? 0,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async *stream(messages, opts) {
|
|
104
|
+
const res = await this.fetchImpl(this.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: this.headers(),
|
|
107
|
+
body: this.body(messages, opts, true),
|
|
108
|
+
signal: opts.signal,
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok || !res.body) {
|
|
111
|
+
throw new Error(`[anthropic] ${res.status} ${res.statusText}: ${await safeText(res)}`);
|
|
112
|
+
}
|
|
113
|
+
const reader = res.body.getReader();
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let buffer = '';
|
|
116
|
+
let inputTokens = 0;
|
|
117
|
+
let outputTokens = 0;
|
|
118
|
+
try {
|
|
119
|
+
for (;;) {
|
|
120
|
+
const { value, done } = await reader.read();
|
|
121
|
+
if (done)
|
|
122
|
+
break;
|
|
123
|
+
buffer += decoder.decode(value, { stream: true });
|
|
124
|
+
let nl;
|
|
125
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
126
|
+
const line = buffer.slice(0, nl).trim();
|
|
127
|
+
buffer = buffer.slice(nl + 1);
|
|
128
|
+
if (!line.startsWith('data:'))
|
|
129
|
+
continue; // skip `event:` and blanks
|
|
130
|
+
const payload = line.slice(5).trim();
|
|
131
|
+
if (!payload || payload === '[DONE]')
|
|
132
|
+
continue;
|
|
133
|
+
const evt = JSON.parse(payload);
|
|
134
|
+
if (evt.type === 'message_start') {
|
|
135
|
+
inputTokens = evt.message?.usage?.input_tokens ?? inputTokens;
|
|
136
|
+
}
|
|
137
|
+
else if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
|
|
138
|
+
if (evt.delta.text)
|
|
139
|
+
yield { type: 'text-delta', delta: evt.delta.text };
|
|
140
|
+
}
|
|
141
|
+
else if (evt.type === 'message_delta') {
|
|
142
|
+
outputTokens = evt.usage?.output_tokens ?? outputTokens;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
reader.releaseLock();
|
|
149
|
+
}
|
|
150
|
+
const usage = { inputTokens, outputTokens };
|
|
151
|
+
yield { type: 'usage', usage };
|
|
152
|
+
yield { type: 'done', usage };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function safeText(res) {
|
|
156
|
+
try {
|
|
157
|
+
return await res.text();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience wiring for a server-side agent: registers the Anthropic provider,
|
|
3
|
+
* a durable `FileAgentMemory`, an `AgentService`, and a hooks bus the SSE route
|
|
4
|
+
* relays from. Hosts that prefer the microkernel can wire these pieces by hand;
|
|
5
|
+
* this helper covers the common Express-server case.
|
|
6
|
+
*/
|
|
7
|
+
import { AgentService, Registry, RemoteToolBridge, type AgentPluginConfig, type IAgentProvider, type IAgentTool } from '@hamak/ai-agent';
|
|
8
|
+
import { type AnthropicProviderConfig } from './anthropic-provider.js';
|
|
9
|
+
import { FileAgentMemory } from './file-agent-memory.js';
|
|
10
|
+
import { type HooksBus } from './hooks-bus.js';
|
|
11
|
+
import { HttpServerChannel } from './http-server-channel.js';
|
|
12
|
+
export interface AgentBackendConfig {
|
|
13
|
+
/** Agent definitions (provider id should match a registered provider). */
|
|
14
|
+
agents: AgentPluginConfig;
|
|
15
|
+
/** Directory for durable conversation files. */
|
|
16
|
+
memoryRoot: string;
|
|
17
|
+
/** Anthropic provider options (apiKey, baseURL). */
|
|
18
|
+
anthropic?: AnthropicProviderConfig;
|
|
19
|
+
/** Extra providers to register beyond the built-in Anthropic one. */
|
|
20
|
+
providers?: IAgentProvider[];
|
|
21
|
+
/** Backend-side tools. */
|
|
22
|
+
tools?: IAgentTool[];
|
|
23
|
+
}
|
|
24
|
+
export interface AgentBackend {
|
|
25
|
+
service: AgentService;
|
|
26
|
+
hooks: HooksBus;
|
|
27
|
+
memory: FileAgentMemory;
|
|
28
|
+
providers: Registry<IAgentProvider>;
|
|
29
|
+
tools: Registry<IAgentTool>;
|
|
30
|
+
/** Split-loop channel — pass to `createAgentRoutes({ channel })` (§4). */
|
|
31
|
+
channel: HttpServerChannel;
|
|
32
|
+
bridge: RemoteToolBridge;
|
|
33
|
+
}
|
|
34
|
+
export declare function createAgentBackend(config: AgentBackendConfig): AgentBackend;
|
|
35
|
+
//# sourceMappingURL=create-agent-backend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-agent-backend.d.ts","sourceRoot":"","sources":["../src/create-agent-backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,0BAA0B;IAC1B,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,eAAe,CAAC;IACxB,SAAS,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CA8B3E"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience wiring for a server-side agent: registers the Anthropic provider,
|
|
3
|
+
* a durable `FileAgentMemory`, an `AgentService`, and a hooks bus the SSE route
|
|
4
|
+
* relays from. Hosts that prefer the microkernel can wire these pieces by hand;
|
|
5
|
+
* this helper covers the common Express-server case.
|
|
6
|
+
*/
|
|
7
|
+
import { AgentService, Registry, RemoteToolBridge, } from '@hamak/ai-agent';
|
|
8
|
+
import { AnthropicProvider } from './anthropic-provider.js';
|
|
9
|
+
import { FileAgentMemory } from './file-agent-memory.js';
|
|
10
|
+
import { createHooksBus } from './hooks-bus.js';
|
|
11
|
+
import { HttpServerChannel } from './http-server-channel.js';
|
|
12
|
+
export function createAgentBackend(config) {
|
|
13
|
+
const providers = new Registry();
|
|
14
|
+
providers.register(new AnthropicProvider(config.anthropic));
|
|
15
|
+
for (const p of config.providers ?? [])
|
|
16
|
+
providers.register(p);
|
|
17
|
+
const tools = new Registry();
|
|
18
|
+
for (const t of config.tools ?? [])
|
|
19
|
+
tools.register(t);
|
|
20
|
+
const memory = new FileAgentMemory(config.memoryRoot);
|
|
21
|
+
const hooks = createHooksBus();
|
|
22
|
+
// Split-loop: a backend channel + bridge so the loop can call frontend tools.
|
|
23
|
+
const channel = new HttpServerChannel();
|
|
24
|
+
const bridge = new RemoteToolBridge({ channel });
|
|
25
|
+
bridge.start();
|
|
26
|
+
const service = new AgentService({
|
|
27
|
+
config: config.agents,
|
|
28
|
+
providers,
|
|
29
|
+
tools,
|
|
30
|
+
memory,
|
|
31
|
+
frontend: {
|
|
32
|
+
specs: () => bridge.specs(),
|
|
33
|
+
has: (n) => bridge.has(n),
|
|
34
|
+
run: (call, sessionId) => bridge.run(call, sessionId),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
service.setHooks(hooks);
|
|
38
|
+
return { service, hooks, memory, providers, tools, channel, bridge };
|
|
39
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable file-system conversation memory (ticket §3: backend "durable /
|
|
3
|
+
* cross-device / audit" source of truth). One JSON file per conversation under
|
|
4
|
+
* `<root>/<agentId>/<sessionId>.json`, so it survives server restarts.
|
|
5
|
+
*
|
|
6
|
+
* This is the backend analogue of the frontend's `StoreFsMemory` — same
|
|
7
|
+
* `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentMessage, Conversation, ConversationSummary } from '@hamak/ai-agent';
|
|
10
|
+
import type { IAgentMemory } from '@hamak/ai-agent/spi';
|
|
11
|
+
export declare class FileAgentMemory implements IAgentMemory {
|
|
12
|
+
private readonly root;
|
|
13
|
+
constructor(root: string);
|
|
14
|
+
private dir;
|
|
15
|
+
private file;
|
|
16
|
+
get(agentId: string, sessionId: string): Promise<Conversation | null>;
|
|
17
|
+
load(agentId: string, sessionId: string): Promise<AgentMessage[]>;
|
|
18
|
+
private write;
|
|
19
|
+
append(agentId: string, sessionId: string, message: AgentMessage): Promise<void>;
|
|
20
|
+
update(agentId: string, sessionId: string, messageId: string, changes: Partial<Pick<AgentMessage, 'content' | 'toolCalls'>>): Promise<void>;
|
|
21
|
+
patch(agentId: string, sessionId: string, changes: Partial<Pick<Conversation, 'title' | 'pinned' | 'systemPrompt'>>): Promise<void>;
|
|
22
|
+
reset(agentId: string, sessionId: string): Promise<void>;
|
|
23
|
+
list(agentId: string): Promise<ConversationSummary[]>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=file-agent-memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-agent-memory.d.ts","sourceRoot":"","sources":["../src/file-agent-memory.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAEvF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAoBxD,qBAAa,eAAgB,YAAW,YAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,MAAM;IAEzC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,IAAI;IAIN,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAUrE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YAIzD,KAAK;IAKb,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CACV,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,WAAW,CAAC,CAAC,GAC5D,OAAO,CAAC,IAAI,CAAC;IAUV,KAAK,CACT,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,QAAQ,GAAG,cAAc,CAAC,CAAC,GACxE,OAAO,CAAC,IAAI,CAAC;IAMV,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxD,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;CAuB5D"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable file-system conversation memory (ticket §3: backend "durable /
|
|
3
|
+
* cross-device / audit" source of truth). One JSON file per conversation under
|
|
4
|
+
* `<root>/<agentId>/<sessionId>.json`, so it survives server restarts.
|
|
5
|
+
*
|
|
6
|
+
* This is the backend analogue of the frontend's `StoreFsMemory` — same
|
|
7
|
+
* `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { CONVERSATION_SCHEMA_VERSION } from '@hamak/ai-agent';
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
function deriveTitle(text) {
|
|
16
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
return 'New conversation';
|
|
19
|
+
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed;
|
|
20
|
+
}
|
|
21
|
+
/** Reject path segments that could escape the root. */
|
|
22
|
+
function safeSegment(seg) {
|
|
23
|
+
if (!seg || seg.includes('/') || seg.includes('\\') || seg.includes('..')) {
|
|
24
|
+
throw new Error(`[ai-agent-backend] unsafe path segment: ${seg}`);
|
|
25
|
+
}
|
|
26
|
+
return seg;
|
|
27
|
+
}
|
|
28
|
+
export class FileAgentMemory {
|
|
29
|
+
constructor(root) {
|
|
30
|
+
this.root = root;
|
|
31
|
+
}
|
|
32
|
+
dir(agentId) {
|
|
33
|
+
return path.join(this.root, safeSegment(agentId));
|
|
34
|
+
}
|
|
35
|
+
file(agentId, sessionId) {
|
|
36
|
+
return path.join(this.dir(agentId), `${safeSegment(sessionId)}.json`);
|
|
37
|
+
}
|
|
38
|
+
async get(agentId, sessionId) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await fs.readFile(this.file(agentId, sessionId), 'utf8');
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.code === 'ENOENT')
|
|
45
|
+
return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async load(agentId, sessionId) {
|
|
50
|
+
return (await this.get(agentId, sessionId))?.messages ?? [];
|
|
51
|
+
}
|
|
52
|
+
async write(agentId, sessionId, conv) {
|
|
53
|
+
await fs.mkdir(this.dir(agentId), { recursive: true });
|
|
54
|
+
await fs.writeFile(this.file(agentId, sessionId), JSON.stringify(conv, null, 2), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
async append(agentId, sessionId, message) {
|
|
57
|
+
const now = nowIso();
|
|
58
|
+
const existing = await this.get(agentId, sessionId);
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.messages.push(message);
|
|
61
|
+
existing.updatedAt = now;
|
|
62
|
+
if (existing.title === '' && message.role === 'user') {
|
|
63
|
+
existing.title = deriveTitle(message.content);
|
|
64
|
+
}
|
|
65
|
+
await this.write(agentId, sessionId, existing);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await this.write(agentId, sessionId, {
|
|
69
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
70
|
+
id: sessionId,
|
|
71
|
+
title: message.role === 'user' ? deriveTitle(message.content) : '',
|
|
72
|
+
messages: [message],
|
|
73
|
+
createdAt: now,
|
|
74
|
+
updatedAt: now,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async update(agentId, sessionId, messageId, changes) {
|
|
78
|
+
const existing = await this.get(agentId, sessionId);
|
|
79
|
+
if (!existing)
|
|
80
|
+
return;
|
|
81
|
+
const idx = existing.messages.findIndex((m) => m.id === messageId);
|
|
82
|
+
if (idx === -1)
|
|
83
|
+
return;
|
|
84
|
+
existing.messages[idx] = { ...existing.messages[idx], ...changes };
|
|
85
|
+
existing.updatedAt = nowIso();
|
|
86
|
+
await this.write(agentId, sessionId, existing);
|
|
87
|
+
}
|
|
88
|
+
async patch(agentId, sessionId, changes) {
|
|
89
|
+
const existing = await this.get(agentId, sessionId);
|
|
90
|
+
if (!existing)
|
|
91
|
+
return;
|
|
92
|
+
await this.write(agentId, sessionId, { ...existing, ...changes, updatedAt: nowIso() });
|
|
93
|
+
}
|
|
94
|
+
async reset(agentId, sessionId) {
|
|
95
|
+
await fs.rm(this.file(agentId, sessionId), { force: true });
|
|
96
|
+
}
|
|
97
|
+
async list(agentId) {
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = await fs.readdir(this.dir(agentId));
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err.code === 'ENOENT')
|
|
104
|
+
return [];
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
if (!name.endsWith('.json'))
|
|
110
|
+
continue;
|
|
111
|
+
const conv = await this.get(agentId, name.slice(0, -'.json'.length));
|
|
112
|
+
if (!conv)
|
|
113
|
+
continue;
|
|
114
|
+
out.push({
|
|
115
|
+
id: conv.id,
|
|
116
|
+
title: conv.title,
|
|
117
|
+
messageCount: conv.messages.length,
|
|
118
|
+
updatedAt: conv.updatedAt,
|
|
119
|
+
pinned: conv.pinned,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny synchronous event bus — the backend analogue of the microkernel hooks
|
|
3
|
+
* surface, so a standalone `AgentService` can emit `ai:*` events that the SSE
|
|
4
|
+
* route subscribes to.
|
|
5
|
+
*/
|
|
6
|
+
export interface HooksBus {
|
|
7
|
+
on(event: string, fn: (...args: unknown[]) => void): void;
|
|
8
|
+
off(event: string, fn: (...args: unknown[]) => void): void;
|
|
9
|
+
emit(event: string, ...args: unknown[]): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createHooksBus(): HooksBus;
|
|
12
|
+
//# sourceMappingURL=hooks-bus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hooks-bus.d.ts","sourceRoot":"","sources":["../src/hooks-bus.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,QAAQ;IACvB,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC1D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAC/C;AAED,wBAAgB,cAAc,IAAI,QAAQ,CAqBzC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny synchronous event bus — the backend analogue of the microkernel hooks
|
|
3
|
+
* surface, so a standalone `AgentService` can emit `ai:*` events that the SSE
|
|
4
|
+
* route subscribes to.
|
|
5
|
+
*/
|
|
6
|
+
export function createHooksBus() {
|
|
7
|
+
const map = new Map();
|
|
8
|
+
return {
|
|
9
|
+
on(event, fn) {
|
|
10
|
+
let set = map.get(event);
|
|
11
|
+
if (!set)
|
|
12
|
+
map.set(event, (set = new Set()));
|
|
13
|
+
set.add(fn);
|
|
14
|
+
},
|
|
15
|
+
off(event, fn) {
|
|
16
|
+
map.get(event)?.delete(fn);
|
|
17
|
+
},
|
|
18
|
+
emit(event, ...args) {
|
|
19
|
+
for (const fn of map.get(event) ?? []) {
|
|
20
|
+
try {
|
|
21
|
+
fn(...args);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* a bad listener must not break the loop */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpServerChannel` — the backend `IAgentChannel` end (ticket §4). Holds the
|
|
3
|
+
* open SSE responses (down-stream) and fans `send()` out to them; `ingest()`
|
|
4
|
+
* feeds POSTed up-envelopes to the registered handlers. The Express channel
|
|
5
|
+
* routes (see `agent-routes.ts`) drive `addClient`/`removeClient`/`ingest`.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentChannelEnvelope } from '@hamak/ai-agent';
|
|
8
|
+
import type { IAgentChannel } from '@hamak/ai-agent/spi';
|
|
9
|
+
export interface SseWritable {
|
|
10
|
+
write(chunk: string): void;
|
|
11
|
+
}
|
|
12
|
+
export declare class HttpServerChannel implements IAgentChannel {
|
|
13
|
+
private readonly clients;
|
|
14
|
+
private readonly handlers;
|
|
15
|
+
send(msg: AgentChannelEnvelope): void;
|
|
16
|
+
onMessage(handler: (m: AgentChannelEnvelope) => void): () => void;
|
|
17
|
+
addClient(res: SseWritable): void;
|
|
18
|
+
removeClient(res: SseWritable): void;
|
|
19
|
+
/** Called by the POST /channel route with a parsed up-envelope. */
|
|
20
|
+
ingest(msg: AgentChannelEnvelope): void;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=http-server-channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-server-channel.d.ts","sourceRoot":"","sources":["../src/http-server-channel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,qBAAa,iBAAkB,YAAW,aAAa;IACrD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgD;IAEzE,IAAI,CAAC,GAAG,EAAE,oBAAoB,GAAG,IAAI;IAWrC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,oBAAoB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKjE,SAAS,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI;IAIjC,YAAY,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI;IAIpC,mEAAmE;IACnE,MAAM,CAAC,GAAG,EAAE,oBAAoB,GAAG,IAAI;CAGxC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpServerChannel` — the backend `IAgentChannel` end (ticket §4). Holds the
|
|
3
|
+
* open SSE responses (down-stream) and fans `send()` out to them; `ingest()`
|
|
4
|
+
* feeds POSTed up-envelopes to the registered handlers. The Express channel
|
|
5
|
+
* routes (see `agent-routes.ts`) drive `addClient`/`removeClient`/`ingest`.
|
|
6
|
+
*/
|
|
7
|
+
export class HttpServerChannel {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.clients = new Set();
|
|
10
|
+
this.handlers = new Set();
|
|
11
|
+
}
|
|
12
|
+
send(msg) {
|
|
13
|
+
const frame = `data: ${JSON.stringify(msg)}\n\n`;
|
|
14
|
+
for (const c of this.clients) {
|
|
15
|
+
try {
|
|
16
|
+
c.write(frame);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* drop a broken client; close handler removes it */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
onMessage(handler) {
|
|
24
|
+
this.handlers.add(handler);
|
|
25
|
+
return () => this.handlers.delete(handler);
|
|
26
|
+
}
|
|
27
|
+
addClient(res) {
|
|
28
|
+
this.clients.add(res);
|
|
29
|
+
}
|
|
30
|
+
removeClient(res) {
|
|
31
|
+
this.clients.delete(res);
|
|
32
|
+
}
|
|
33
|
+
/** Called by the POST /channel route with a parsed up-envelope. */
|
|
34
|
+
ingest(msg) {
|
|
35
|
+
for (const h of [...this.handlers])
|
|
36
|
+
h(msg);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hamak/ai-agent-backend — server-side pieces for the AI agent plugin.
|
|
3
|
+
*
|
|
4
|
+
* Hosted-model provider (Anthropic), durable file-backed memory, a hooks bus,
|
|
5
|
+
* and an Express agent-chat route. See `docs/README.md` and ticket §10 PR2.
|
|
6
|
+
*/
|
|
7
|
+
export { AnthropicProvider } from './anthropic-provider.js';
|
|
8
|
+
export type { AnthropicProviderConfig } from './anthropic-provider.js';
|
|
9
|
+
export { FileAgentMemory } from './file-agent-memory.js';
|
|
10
|
+
export { createHooksBus } from './hooks-bus.js';
|
|
11
|
+
export type { HooksBus } from './hooks-bus.js';
|
|
12
|
+
export { createAgentRoutes } from './agent-routes.js';
|
|
13
|
+
export type { AgentRoutesConfig } from './agent-routes.js';
|
|
14
|
+
export { createAgentBackend } from './create-agent-backend.js';
|
|
15
|
+
export type { AgentBackend, AgentBackendConfig } from './create-agent-backend.js';
|
|
16
|
+
export { HttpServerChannel } from './http-server-channel.js';
|
|
17
|
+
export type { SseWritable } from './http-server-channel.js';
|
|
18
|
+
export { StdioMcpConnector, StdioMcpClient } from './stdio-mcp.js';
|
|
19
|
+
export type { StdioMcpClientConfig } from './stdio-mcp.js';
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,YAAY,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACpE,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAChE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hamak/ai-agent-backend — server-side pieces for the AI agent plugin.
|
|
3
|
+
*
|
|
4
|
+
* Hosted-model provider (Anthropic), durable file-backed memory, a hooks bus,
|
|
5
|
+
* and an Express agent-chat route. See `docs/README.md` and ticket §10 PR2.
|
|
6
|
+
*/
|
|
7
|
+
export { AnthropicProvider } from './anthropic-provider.js';
|
|
8
|
+
export { FileAgentMemory } from './file-agent-memory.js';
|
|
9
|
+
export { createHooksBus } from './hooks-bus.js';
|
|
10
|
+
export { createAgentRoutes } from './agent-routes.js';
|
|
11
|
+
export { createAgentBackend } from './create-agent-backend.js';
|
|
12
|
+
export { HttpServerChannel } from './http-server-channel.js';
|
|
13
|
+
export { StdioMcpConnector, StdioMcpClient } from './stdio-mcp.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real stdio MCP connector (ticket §11). Spawns the server subprocess and
|
|
3
|
+
* speaks newline-delimited JSON-RPC 2.0 over stdin/stdout — the MCP stdio wire
|
|
4
|
+
* format. No SDK dependency; `@modelcontextprotocol/sdk`'s `StdioClientTransport`
|
|
5
|
+
* is an equivalent drop-in behind the same `McpConnector` interface.
|
|
6
|
+
*
|
|
7
|
+
* stdio requires a process host, so this lives in the backend package.
|
|
8
|
+
*/
|
|
9
|
+
import type { McpServerConfig, McpToolDef } from '@hamak/ai-agent';
|
|
10
|
+
import type { IMcpClient, McpConnector } from '@hamak/ai-agent/spi';
|
|
11
|
+
export interface StdioMcpClientConfig {
|
|
12
|
+
command: string;
|
|
13
|
+
args?: string[];
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
clientInfo?: {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
};
|
|
20
|
+
/** Per-request timeout. Default 10s. */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class StdioMcpClient implements IMcpClient {
|
|
24
|
+
private readonly proc;
|
|
25
|
+
private readonly pending;
|
|
26
|
+
private readonly clientInfo;
|
|
27
|
+
private readonly timeoutMs;
|
|
28
|
+
private rpcId;
|
|
29
|
+
private buffer;
|
|
30
|
+
private initialized;
|
|
31
|
+
private closed;
|
|
32
|
+
constructor(config: StdioMcpClientConfig);
|
|
33
|
+
private failAll;
|
|
34
|
+
private onData;
|
|
35
|
+
private rpc;
|
|
36
|
+
private ensureInitialized;
|
|
37
|
+
listTools(): Promise<McpToolDef[]>;
|
|
38
|
+
callTool(name: string, input: unknown): Promise<unknown>;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/** Connector for `transport: 'stdio'` MCP servers. */
|
|
42
|
+
export declare class StdioMcpConnector implements McpConnector {
|
|
43
|
+
connect(id: string, config: McpServerConfig): Promise<IMcpClient>;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=stdio-mcp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stdio-mcp.d.ts","sourceRoot":"","sources":["../src/stdio-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIpE,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/C,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAQD,qBAAa,cAAe,YAAW,UAAU;IAC/C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAiC;IACtD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8B;IACtD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAoC;IAC/D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,MAAM,CAAS;gBAEX,MAAM,EAAE,oBAAoB;IAgBxC,OAAO,CAAC,OAAO;IAQf,OAAO,CAAC,MAAM;IAuBd,OAAO,CAAC,GAAG;YAmBG,iBAAiB;IAWzB,SAAS,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAYlC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAKxD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAS7B;AAED,sDAAsD;AACtD,qBAAa,iBAAkB,YAAW,YAAY;IAC9C,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC;CAYxE"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real stdio MCP connector (ticket §11). Spawns the server subprocess and
|
|
3
|
+
* speaks newline-delimited JSON-RPC 2.0 over stdin/stdout — the MCP stdio wire
|
|
4
|
+
* format. No SDK dependency; `@modelcontextprotocol/sdk`'s `StdioClientTransport`
|
|
5
|
+
* is an equivalent drop-in behind the same `McpConnector` interface.
|
|
6
|
+
*
|
|
7
|
+
* stdio requires a process host, so this lives in the backend package.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
11
|
+
export class StdioMcpClient {
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.pending = new Map();
|
|
14
|
+
this.rpcId = 0;
|
|
15
|
+
this.buffer = '';
|
|
16
|
+
this.initialized = false;
|
|
17
|
+
this.closed = false;
|
|
18
|
+
this.clientInfo = config.clientInfo ?? { name: '@hamak/ai-agent', version: '0' };
|
|
19
|
+
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
20
|
+
this.proc = spawn(config.command, config.args ?? [], {
|
|
21
|
+
env: { ...process.env, ...config.env },
|
|
22
|
+
cwd: config.cwd,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
});
|
|
25
|
+
this.proc.stdout.setEncoding('utf8');
|
|
26
|
+
this.proc.stdout.on('data', (chunk) => this.onData(chunk));
|
|
27
|
+
this.proc.on('error', (err) => this.failAll(err));
|
|
28
|
+
this.proc.on('exit', () => {
|
|
29
|
+
if (!this.closed)
|
|
30
|
+
this.failAll(new Error('[mcp] server process exited'));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
failAll(err) {
|
|
34
|
+
for (const [, p] of this.pending) {
|
|
35
|
+
clearTimeout(p.timer);
|
|
36
|
+
p.reject(err);
|
|
37
|
+
}
|
|
38
|
+
this.pending.clear();
|
|
39
|
+
}
|
|
40
|
+
onData(chunk) {
|
|
41
|
+
this.buffer += chunk;
|
|
42
|
+
let nl;
|
|
43
|
+
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
|
44
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
45
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
46
|
+
if (!line)
|
|
47
|
+
continue;
|
|
48
|
+
let msg;
|
|
49
|
+
try {
|
|
50
|
+
msg = JSON.parse(line);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue; // ignore non-JSON (servers may log to stdout by mistake)
|
|
54
|
+
}
|
|
55
|
+
if (msg.id != null && this.pending.has(msg.id)) {
|
|
56
|
+
const p = this.pending.get(msg.id);
|
|
57
|
+
this.pending.delete(msg.id);
|
|
58
|
+
clearTimeout(p.timer);
|
|
59
|
+
if (msg.error)
|
|
60
|
+
p.reject(new Error(`[mcp] ${msg.error.message ?? 'rpc error'}`));
|
|
61
|
+
else
|
|
62
|
+
p.resolve(msg.result);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
rpc(method, params, notification = false) {
|
|
67
|
+
const id = notification ? undefined : (this.rpcId += 1);
|
|
68
|
+
const payload = JSON.stringify({
|
|
69
|
+
jsonrpc: '2.0',
|
|
70
|
+
...(notification ? {} : { id }),
|
|
71
|
+
method,
|
|
72
|
+
...(params !== undefined ? { params } : {}),
|
|
73
|
+
});
|
|
74
|
+
this.proc.stdin.write(`${payload}\n`);
|
|
75
|
+
if (notification)
|
|
76
|
+
return Promise.resolve(undefined);
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
this.pending.delete(id);
|
|
80
|
+
reject(new Error(`[mcp] timeout waiting for ${method}`));
|
|
81
|
+
}, this.timeoutMs);
|
|
82
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async ensureInitialized() {
|
|
86
|
+
if (this.initialized)
|
|
87
|
+
return;
|
|
88
|
+
await this.rpc('initialize', {
|
|
89
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
90
|
+
capabilities: {},
|
|
91
|
+
clientInfo: this.clientInfo,
|
|
92
|
+
});
|
|
93
|
+
await this.rpc('notifications/initialized', undefined, true);
|
|
94
|
+
this.initialized = true;
|
|
95
|
+
}
|
|
96
|
+
async listTools() {
|
|
97
|
+
await this.ensureInitialized();
|
|
98
|
+
const result = await this.rpc('tools/list');
|
|
99
|
+
const tools = result?.tools ?? [];
|
|
100
|
+
return tools.map((t) => ({
|
|
101
|
+
name: t.name,
|
|
102
|
+
description: t.description,
|
|
103
|
+
inputSchema: t.inputSchema,
|
|
104
|
+
readOnlyHint: t.annotations?.readOnlyHint,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
async callTool(name, input) {
|
|
108
|
+
await this.ensureInitialized();
|
|
109
|
+
return this.rpc('tools/call', { name, arguments: input ?? {} });
|
|
110
|
+
}
|
|
111
|
+
async close() {
|
|
112
|
+
this.closed = true;
|
|
113
|
+
try {
|
|
114
|
+
this.proc.stdin.end();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* already closed */
|
|
118
|
+
}
|
|
119
|
+
this.proc.kill();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Connector for `transport: 'stdio'` MCP servers. */
|
|
123
|
+
export class StdioMcpConnector {
|
|
124
|
+
async connect(id, config) {
|
|
125
|
+
if (config.definition.transport !== 'stdio') {
|
|
126
|
+
throw new Error(`[mcp] StdioMcpConnector only supports stdio transport (server "${id}")`);
|
|
127
|
+
}
|
|
128
|
+
return new StdioMcpClient({
|
|
129
|
+
command: config.definition.command,
|
|
130
|
+
args: config.definition.args,
|
|
131
|
+
env: config.definition.env,
|
|
132
|
+
cwd: config.definition.cwd,
|
|
133
|
+
timeoutMs: config.timeoutMs,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hamak/ai-agent-backend",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Backend for @hamak/ai-agent: hosted-model providers (Anthropic), durable memory, and an Express agent-chat route",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/amah/app-framework.git",
|
|
16
|
+
"directory": "packages/ai-agent/backend/ai-agent-backend"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc && node ../../../../scripts/fix-esm-extensions.mjs ./dist",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@hamak/ai-agent": "*"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"express": "^4.18.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"express": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/express": "^4.17.17",
|
|
47
|
+
"@types/node": "^20.0.0",
|
|
48
|
+
"typescript": "~5.4.0",
|
|
49
|
+
"vitest": "^2.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|