@softov/ahpc 0.1.0 → 0.2.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/README.md +40 -1
- package/dist/src/ahp/channels.js +10 -3
- package/dist/src/ahp/publish.js +31 -6
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +70 -98
- package/dist/src/flags.js +4 -0
- package/dist/src/mcp/http.d.ts +24 -0
- package/dist/src/mcp/http.js +140 -0
- package/dist/src/mcp/serve.d.ts +58 -0
- package/dist/src/mcp/serve.js +118 -0
- package/dist/src/mcp/stdio.d.ts +13 -0
- package/dist/src/mcp/stdio.js +58 -0
- package/dist/src/mcp/tools.d.ts +33 -0
- package/dist/src/mcp/tools.js +328 -0
- package/dist/src/wait.d.ts +45 -0
- package/dist/src/wait.js +147 -0
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
/** The version of MCP this speaks, and the one it answers `initialize` with. */
|
|
3
|
+
export declare const PROTOCOL = "2025-06-18";
|
|
4
|
+
/**
|
|
5
|
+
* What this server calls itself.
|
|
6
|
+
*
|
|
7
|
+
* MCP requires a version where AHP's `clientInfo` does not, so this is the
|
|
8
|
+
* only version string in the source. Cosmetic - a client displays it and
|
|
9
|
+
* nothing branches on it - and deliberately not read out of `package.json`,
|
|
10
|
+
* which sits at a different depth in the published tree than it does here.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SERVER: {
|
|
13
|
+
readonly name: "ahpc";
|
|
14
|
+
readonly version: "0.1";
|
|
15
|
+
};
|
|
16
|
+
/** A JSON-RPC request or notification, as far as this needs to read one. */
|
|
17
|
+
export interface Incoming {
|
|
18
|
+
jsonrpc?: unknown;
|
|
19
|
+
id?: number | string | null;
|
|
20
|
+
method?: unknown;
|
|
21
|
+
params?: unknown;
|
|
22
|
+
}
|
|
23
|
+
/** What goes back, or nothing at all where the message was a notification. */
|
|
24
|
+
export type Outgoing = {
|
|
25
|
+
jsonrpc: '2.0';
|
|
26
|
+
id: number | string | null;
|
|
27
|
+
} & ({
|
|
28
|
+
result: unknown;
|
|
29
|
+
error?: never;
|
|
30
|
+
} | {
|
|
31
|
+
error: {
|
|
32
|
+
code: number;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
result?: never;
|
|
36
|
+
});
|
|
37
|
+
/** The tools, in the shape `tools/list` puts them on the wire. */
|
|
38
|
+
export declare const listing: () => unknown;
|
|
39
|
+
/**
|
|
40
|
+
* Run one tool and shape the answer the way `tools/call` wants it.
|
|
41
|
+
*
|
|
42
|
+
* A tool that throws is *not* a protocol error. MCP has `isError` on the
|
|
43
|
+
* result for exactly this: the call reached the tool and the tool said no,
|
|
44
|
+
* which a model can read and act on, where a JSON-RPC error is a transport
|
|
45
|
+
* fault it can only give up over. So a refusal from the host - a session that
|
|
46
|
+
* is gone, a directory it does not serve - comes back as content.
|
|
47
|
+
*/
|
|
48
|
+
export declare function call(host: HostConnection, name: string, input: unknown): Promise<unknown>;
|
|
49
|
+
/**
|
|
50
|
+
* Answer one message.
|
|
51
|
+
*
|
|
52
|
+
* `undefined` where there is nothing to send back, which is a notification -
|
|
53
|
+
* and answering one anyway is a protocol error on this end, not a courtesy.
|
|
54
|
+
*/
|
|
55
|
+
export declare function answer(host: HostConnection, message: Incoming, options: {
|
|
56
|
+
name: string;
|
|
57
|
+
version: string;
|
|
58
|
+
}): Promise<Outgoing | undefined>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP, spoken directly.
|
|
3
|
+
*
|
|
4
|
+
* The protocol a tools-only server has to answer is small: `initialize`, an
|
|
5
|
+
* `initialized` notification with no reply, `tools/list`, `tools/call`, and
|
|
6
|
+
* `ping`. All of it is JSON-RPC 2.0, which this client already reads and
|
|
7
|
+
* writes for AHP. An SDK for those five would be a dependency the published
|
|
8
|
+
* CLI carries into every install to save a hundred lines - the same trade
|
|
9
|
+
* `flags.ts` refuses for argv.
|
|
10
|
+
*
|
|
11
|
+
* What is deliberately not here: prompts, resources, sampling, roots and
|
|
12
|
+
* subscriptions. A server that advertises no capability for them is not
|
|
13
|
+
* obliged to answer them, and a caller that asks is told the method is not
|
|
14
|
+
* there, which is the truth.
|
|
15
|
+
*/
|
|
16
|
+
import { TOOLS, named } from './tools.js';
|
|
17
|
+
/** The version of MCP this speaks, and the one it answers `initialize` with. */
|
|
18
|
+
export const PROTOCOL = '2025-06-18';
|
|
19
|
+
/**
|
|
20
|
+
* What this server calls itself.
|
|
21
|
+
*
|
|
22
|
+
* MCP requires a version where AHP's `clientInfo` does not, so this is the
|
|
23
|
+
* only version string in the source. Cosmetic - a client displays it and
|
|
24
|
+
* nothing branches on it - and deliberately not read out of `package.json`,
|
|
25
|
+
* which sits at a different depth in the published tree than it does here.
|
|
26
|
+
*/
|
|
27
|
+
export const SERVER = { name: 'ahpc', version: '0.1' };
|
|
28
|
+
const METHOD_NOT_FOUND = -32601;
|
|
29
|
+
const INVALID_PARAMS = -32602;
|
|
30
|
+
const INTERNAL = -32603;
|
|
31
|
+
const bag = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
|
|
32
|
+
/** The tools, in the shape `tools/list` puts them on the wire. */
|
|
33
|
+
export const listing = () => ({
|
|
34
|
+
tools: TOOLS.map((tool) => ({
|
|
35
|
+
name: tool.name,
|
|
36
|
+
title: tool.title,
|
|
37
|
+
description: tool.description,
|
|
38
|
+
inputSchema: tool.input,
|
|
39
|
+
annotations: { readOnlyHint: tool.readOnly },
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Run one tool and shape the answer the way `tools/call` wants it.
|
|
44
|
+
*
|
|
45
|
+
* A tool that throws is *not* a protocol error. MCP has `isError` on the
|
|
46
|
+
* result for exactly this: the call reached the tool and the tool said no,
|
|
47
|
+
* which a model can read and act on, where a JSON-RPC error is a transport
|
|
48
|
+
* fault it can only give up over. So a refusal from the host - a session that
|
|
49
|
+
* is gone, a directory it does not serve - comes back as content.
|
|
50
|
+
*/
|
|
51
|
+
export async function call(host, name, input) {
|
|
52
|
+
const tool = named(name);
|
|
53
|
+
if (tool === undefined) {
|
|
54
|
+
return {
|
|
55
|
+
isError: true,
|
|
56
|
+
content: [{ type: 'text', text: `No tool called ${name}. Ask tools/list for what there is.` }],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const answer = await tool.run(host, bag(input));
|
|
61
|
+
return {
|
|
62
|
+
// Both, because clients differ: the text is what a model reads and
|
|
63
|
+
// `structuredContent` is what a program does, and sending only the
|
|
64
|
+
// second leaves older clients with an empty result.
|
|
65
|
+
content: [{ type: 'text', text: JSON.stringify(answer, null, 2) }],
|
|
66
|
+
structuredContent: bag(answer).constructor === Object && !Array.isArray(answer) && answer !== null
|
|
67
|
+
? answer
|
|
68
|
+
: { result: answer },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
isError: true,
|
|
74
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Answer one message.
|
|
80
|
+
*
|
|
81
|
+
* `undefined` where there is nothing to send back, which is a notification -
|
|
82
|
+
* and answering one anyway is a protocol error on this end, not a courtesy.
|
|
83
|
+
*/
|
|
84
|
+
export async function answer(host, message, options) {
|
|
85
|
+
const method = typeof message.method === 'string' ? message.method : '';
|
|
86
|
+
const id = message.id ?? null;
|
|
87
|
+
const notification = message.id === undefined;
|
|
88
|
+
if (method === 'notifications/initialized' || method.startsWith('notifications/'))
|
|
89
|
+
return undefined;
|
|
90
|
+
if (notification)
|
|
91
|
+
return undefined;
|
|
92
|
+
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
93
|
+
const no = (code, said) => ({ jsonrpc: '2.0', id, error: { code, message: said } });
|
|
94
|
+
if (method === 'initialize') {
|
|
95
|
+
return ok({
|
|
96
|
+
protocolVersion: PROTOCOL,
|
|
97
|
+
capabilities: { tools: { listChanged: false } },
|
|
98
|
+
serverInfo: { name: options.name, version: options.version },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (method === 'ping')
|
|
102
|
+
return ok({});
|
|
103
|
+
if (method === 'tools/list')
|
|
104
|
+
return ok(listing());
|
|
105
|
+
if (method === 'tools/call') {
|
|
106
|
+
const params = bag(message.params);
|
|
107
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
108
|
+
if (name === '')
|
|
109
|
+
return no(INVALID_PARAMS, 'tools/call needs a name.');
|
|
110
|
+
try {
|
|
111
|
+
return ok(await call(host, name, params.arguments));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return no(INTERNAL, error instanceof Error ? error.message : String(error));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return no(METHOD_NOT_FOUND, `This server does not implement ${method}. It serves tools and nothing else.`);
|
|
118
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
/**
|
|
3
|
+
* Serve until stdin closes.
|
|
4
|
+
*
|
|
5
|
+
* Sequential rather than concurrent: `send_turn` blocks for as long as a model
|
|
6
|
+
* takes, and running the next message while it waits would answer out of
|
|
7
|
+
* order. A client that wants two things at once opens two sessions.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stdio(host: HostConnection, options: {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
onProblem?(said: string): void;
|
|
13
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP over the process's own standard streams.
|
|
3
|
+
*
|
|
4
|
+
* The transport a client that launched this process uses: one JSON-RPC
|
|
5
|
+
* message per line on stdin, one per line on stdout, and nothing else on
|
|
6
|
+
* stdout ever - a stray `console.log` here is a parse error at the other end,
|
|
7
|
+
* which is why everything this says goes to stderr.
|
|
8
|
+
*/
|
|
9
|
+
import { answer } from './serve.js';
|
|
10
|
+
/**
|
|
11
|
+
* Serve until stdin closes.
|
|
12
|
+
*
|
|
13
|
+
* Sequential rather than concurrent: `send_turn` blocks for as long as a model
|
|
14
|
+
* takes, and running the next message while it waits would answer out of
|
|
15
|
+
* order. A client that wants two things at once opens two sessions.
|
|
16
|
+
*/
|
|
17
|
+
export async function stdio(host, options) {
|
|
18
|
+
const say = (value) => { process.stdout.write(`${JSON.stringify(value)}\n`); };
|
|
19
|
+
let rest = '';
|
|
20
|
+
await new Promise((done) => {
|
|
21
|
+
let running = Promise.resolve();
|
|
22
|
+
const take = (line) => {
|
|
23
|
+
const said = line.trim();
|
|
24
|
+
if (said === '')
|
|
25
|
+
return;
|
|
26
|
+
running = running.then(async () => {
|
|
27
|
+
let message;
|
|
28
|
+
try {
|
|
29
|
+
message = JSON.parse(said);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// No id to answer against, so there is nobody to tell. Said on
|
|
33
|
+
// stderr, where a person debugging their client will find it.
|
|
34
|
+
options.onProblem?.(`Not JSON: ${said.slice(0, 200)}`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const reply = await answer(host, message, options);
|
|
38
|
+
if (reply !== undefined)
|
|
39
|
+
say(reply);
|
|
40
|
+
}).catch((error) => {
|
|
41
|
+
options.onProblem?.(error instanceof Error ? error.message : String(error));
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
process.stdin.setEncoding('utf8');
|
|
45
|
+
process.stdin.on('data', (chunk) => {
|
|
46
|
+
rest += chunk;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const at = rest.indexOf('\n');
|
|
49
|
+
if (at === -1)
|
|
50
|
+
break;
|
|
51
|
+
take(rest.slice(0, at));
|
|
52
|
+
rest = rest.slice(at + 1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
process.stdin.on('end', () => { take(rest); rest = ''; void running.then(() => done()); });
|
|
56
|
+
process.stdin.on('close', () => { void running.then(() => done()); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
/** As much of JSON Schema as a tool's arguments need. */
|
|
3
|
+
export interface Schema {
|
|
4
|
+
type: 'object';
|
|
5
|
+
properties: Record<string, {
|
|
6
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
7
|
+
description?: string;
|
|
8
|
+
items?: {
|
|
9
|
+
type: string;
|
|
10
|
+
};
|
|
11
|
+
additionalProperties?: boolean | {
|
|
12
|
+
type: string;
|
|
13
|
+
};
|
|
14
|
+
}>;
|
|
15
|
+
required?: string[];
|
|
16
|
+
additionalProperties?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** One tool, as MCP describes it and as this client runs it. */
|
|
19
|
+
export interface Tool {
|
|
20
|
+
/** The name a caller uses. MCP has no dots, so these are underscored. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** A short label, for a client that shows one. */
|
|
23
|
+
title: string;
|
|
24
|
+
/** What it does and when to reach for it, written for a model. */
|
|
25
|
+
description: string;
|
|
26
|
+
input: Schema;
|
|
27
|
+
/** Whether it changes anything, which some clients ask a person about. */
|
|
28
|
+
readOnly: boolean;
|
|
29
|
+
run(host: HostConnection, input: Record<string, unknown>): Promise<unknown>;
|
|
30
|
+
}
|
|
31
|
+
export declare const TOOLS: Tool[];
|
|
32
|
+
/** One tool by the name a caller used, or nothing. */
|
|
33
|
+
export declare const named: (name: string) => Tool | undefined;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What an agent somewhere else can do to a host through this client.
|
|
3
|
+
*
|
|
4
|
+
* One table, three surfaces. `stdio.ts` serves it to a client that launched
|
|
5
|
+
* this process, `http.ts` serves the same table over a socket as MCP and as a
|
|
6
|
+
* plain JSON API, and none of them holds any knowledge of what a tool does:
|
|
7
|
+
* everything below is a name, a JSON Schema, and a few lines against the
|
|
8
|
+
* `HostConnection` this client already opens.
|
|
9
|
+
*
|
|
10
|
+
* The schemas are written out rather than generated. They go on the wire as
|
|
11
|
+
* JSON Schema whatever produces them, this is the only place they exist, and
|
|
12
|
+
* a validator dependency to describe five objects would be a dependency for
|
|
13
|
+
* its own sake - which is the same reason `flags.ts` scans argv by hand.
|
|
14
|
+
*
|
|
15
|
+
* Which tools: the ones that let an agent drive a session to completion, and
|
|
16
|
+
* not the whole of what `ahpc` can do. A tool table is read by a model with
|
|
17
|
+
* everything else it has been given, so forty of them is a worse server than
|
|
18
|
+
* eleven. Files, terminals, automations and changesets are deliberately not
|
|
19
|
+
* here; see ROADMAP.md.
|
|
20
|
+
*/
|
|
21
|
+
import { SessionFlag } from '../ahp/types.js';
|
|
22
|
+
import { spoken, turn as runTurn, until } from '../wait.js';
|
|
23
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
24
|
+
const uriOf = (input) => {
|
|
25
|
+
const found = text(input.session);
|
|
26
|
+
if (found === '')
|
|
27
|
+
throw new Error('No session was given. Every session tool takes one from list_sessions or new_session.');
|
|
28
|
+
return found;
|
|
29
|
+
};
|
|
30
|
+
/** A turn as an answer rather than a tree of parts, which is what a model reads. */
|
|
31
|
+
const said = (one) => ({
|
|
32
|
+
id: one.id,
|
|
33
|
+
role: one.role,
|
|
34
|
+
state: one.state,
|
|
35
|
+
at: one.at,
|
|
36
|
+
...(one.message === undefined ? {} : { message: one.message }),
|
|
37
|
+
text: spoken(one),
|
|
38
|
+
tools: one.parts
|
|
39
|
+
.filter((part) => part.kind === 'toolCall')
|
|
40
|
+
.map((part) => (part.kind === 'toolCall'
|
|
41
|
+
? { id: part.call.id, name: part.call.name, status: part.call.status }
|
|
42
|
+
: null))
|
|
43
|
+
.filter((one_) => one_ !== null),
|
|
44
|
+
});
|
|
45
|
+
export const TOOLS = [
|
|
46
|
+
{
|
|
47
|
+
name: 'list_sessions',
|
|
48
|
+
title: 'List sessions',
|
|
49
|
+
description: 'Every session on the connected host, newest first. Start here: every other session tool takes a session URI from this list.',
|
|
50
|
+
readOnly: true,
|
|
51
|
+
input: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
archived: { type: 'boolean', description: 'Include archived sessions. Off by default.' },
|
|
55
|
+
},
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
run: async (host, input) => {
|
|
59
|
+
const rows = await host.listSessions();
|
|
60
|
+
const wanted = input.archived === true
|
|
61
|
+
? rows
|
|
62
|
+
: rows.filter((row) => (row.status & SessionFlag.IsArchived) === 0);
|
|
63
|
+
return wanted.map((row) => ({
|
|
64
|
+
session: row.resource,
|
|
65
|
+
title: row.title,
|
|
66
|
+
provider: row.provider,
|
|
67
|
+
status: row.status,
|
|
68
|
+
modifiedAt: row.modifiedAt,
|
|
69
|
+
workingDirectories: row.workingDirectories,
|
|
70
|
+
...(row.activity === undefined ? {} : { activity: row.activity }),
|
|
71
|
+
}));
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'show_session',
|
|
76
|
+
title: 'Show one session',
|
|
77
|
+
description: 'What the host says about one session: its title, what it is doing, and the directories it works in.',
|
|
78
|
+
readOnly: true,
|
|
79
|
+
input: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { session: { type: 'string', description: 'A session URI from list_sessions.' } },
|
|
82
|
+
required: ['session'],
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
},
|
|
85
|
+
run: async (host, input) => host.detail(uriOf(input)),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'list_agents',
|
|
89
|
+
title: 'List harnesses',
|
|
90
|
+
description: 'The harnesses this host serves and the models each offers. The provider name from here is what new_session takes.',
|
|
91
|
+
readOnly: true,
|
|
92
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
93
|
+
run: async (host) => (await host.agents()).map((agent) => ({
|
|
94
|
+
provider: agent.provider,
|
|
95
|
+
name: agent.displayName,
|
|
96
|
+
...(agent.description === undefined ? {} : { description: agent.description }),
|
|
97
|
+
models: agent.models.map((model) => model.id),
|
|
98
|
+
})),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'new_session',
|
|
102
|
+
title: 'Start a session',
|
|
103
|
+
description: 'Create a session on the host and return its URI. The agent is not asked anything until send_turn.',
|
|
104
|
+
readOnly: false,
|
|
105
|
+
input: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
provider: { type: 'string', description: 'A provider from list_agents. The first one the host serves, if omitted.' },
|
|
109
|
+
workingDirectory: { type: 'string', description: 'An absolute path on the host for the agent to work in.' },
|
|
110
|
+
config: { type: 'object', description: 'Configuration values, as a flat object of strings.', additionalProperties: { type: 'string' } },
|
|
111
|
+
},
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
},
|
|
114
|
+
run: async (host, input) => {
|
|
115
|
+
const provider = text(input.provider) || (await host.agents())[0]?.provider;
|
|
116
|
+
if (provider === undefined)
|
|
117
|
+
throw new Error('This host advertises no harness to start a session on.');
|
|
118
|
+
const config = typeof input.config === 'object' && input.config !== null
|
|
119
|
+
? input.config
|
|
120
|
+
: undefined;
|
|
121
|
+
const where = text(input.workingDirectory);
|
|
122
|
+
return {
|
|
123
|
+
session: await host.createSession({
|
|
124
|
+
provider,
|
|
125
|
+
...(where === '' ? {} : { workingDirectory: where }),
|
|
126
|
+
...(config === undefined ? {} : { config }),
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'dispose_session',
|
|
133
|
+
title: 'End a session',
|
|
134
|
+
description: 'Dispose of a session. The transcript is the host\'s to keep or drop; the agent behind it stops.',
|
|
135
|
+
readOnly: false,
|
|
136
|
+
input: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: { session: { type: 'string' } },
|
|
139
|
+
required: ['session'],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
run: async (host, input) => { await host.disposeSession(uriOf(input)); return { disposed: true }; },
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'session_history',
|
|
146
|
+
title: 'Read a transcript',
|
|
147
|
+
description: 'The turns in a session, oldest first, as text rather than as a tree of parts.',
|
|
148
|
+
readOnly: true,
|
|
149
|
+
input: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
session: { type: 'string' },
|
|
153
|
+
all: { type: 'boolean', description: 'Load the whole conversation rather than the window the host opened with.' },
|
|
154
|
+
},
|
|
155
|
+
required: ['session'],
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
},
|
|
158
|
+
run: async (host, input) => {
|
|
159
|
+
const uri = uriOf(input);
|
|
160
|
+
if (input.all === true) {
|
|
161
|
+
// Bounded, because a conversation somebody has been having for a year
|
|
162
|
+
// is one this would otherwise read to the end of before answering.
|
|
163
|
+
for (let page = 0; page < 100; page += 1) {
|
|
164
|
+
if (!await host.loadOlderTurns(uri))
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const event = await until(host, uri, (one) => one.type === 'snapshot', { timeoutSeconds: 30 });
|
|
169
|
+
if (event?.type !== 'snapshot')
|
|
170
|
+
throw new Error(`${uri} said nothing within thirty seconds.`);
|
|
171
|
+
return {
|
|
172
|
+
turns: event.turns.map(said),
|
|
173
|
+
...(event.active === undefined ? {} : { running: said(event.active) }),
|
|
174
|
+
...(event.input === undefined ? {} : { waitingOn: event.input }),
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'send_turn',
|
|
180
|
+
title: 'Say something and wait',
|
|
181
|
+
description: 'Say something to a session and block until the agent has finished answering, then return what it said. A turn that stops to ask a person something is not finished: this keeps waiting, and wait_for_attention is how to find out what it wants.',
|
|
182
|
+
readOnly: false,
|
|
183
|
+
input: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
session: { type: 'string' },
|
|
187
|
+
text: { type: 'string', description: 'What to say.' },
|
|
188
|
+
model: { type: 'string', description: 'A model id from list_agents. The session\'s own, if omitted.' },
|
|
189
|
+
timeoutSeconds: { type: 'number', description: 'How long to wait. 900 by default.' },
|
|
190
|
+
},
|
|
191
|
+
required: ['session', 'text'],
|
|
192
|
+
additionalProperties: false,
|
|
193
|
+
},
|
|
194
|
+
run: async (host, input) => {
|
|
195
|
+
const model = text(input.model);
|
|
196
|
+
const answer = await runTurn(host, uriOf(input), text(input.text), {
|
|
197
|
+
...(model === '' ? {} : { model: { id: model } }),
|
|
198
|
+
...(typeof input.timeoutSeconds === 'number' ? { timeoutSeconds: input.timeoutSeconds } : {}),
|
|
199
|
+
});
|
|
200
|
+
// Not an error: the turn is still running and the session is still
|
|
201
|
+
// there, which is a different thing to tell a caller than a failure.
|
|
202
|
+
return answer === undefined ? { state: 'timeout' } : said(answer);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'queue_turn',
|
|
207
|
+
title: 'Say something after this one',
|
|
208
|
+
description: 'Queue a message to be said once the running turn finishes. Returns at once, unlike send_turn.',
|
|
209
|
+
readOnly: false,
|
|
210
|
+
input: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
properties: { session: { type: 'string' }, text: { type: 'string' } },
|
|
213
|
+
required: ['session', 'text'],
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
},
|
|
216
|
+
run: async (host, input) => { host.queue(uriOf(input), text(input.text)); return { queued: true }; },
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'cancel_turn',
|
|
220
|
+
title: 'Stop the running turn',
|
|
221
|
+
description: 'Interrupt whatever the agent is doing. What it had already said stays in the transcript.',
|
|
222
|
+
readOnly: false,
|
|
223
|
+
input: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: { session: { type: 'string' } },
|
|
226
|
+
required: ['session'],
|
|
227
|
+
additionalProperties: false,
|
|
228
|
+
},
|
|
229
|
+
run: async (host, input) => { host.stopTurn(uriOf(input)); return { cancelled: true }; },
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'wait_for_attention',
|
|
233
|
+
title: 'Wait until something wants a person',
|
|
234
|
+
description: 'Block until the session needs an answer - a tool call to approve, a question to fill in - or until it goes quiet, and say which. This is what makes a session drivable from here: without it a caller has no way to see what send_turn is waiting on.',
|
|
235
|
+
readOnly: true,
|
|
236
|
+
input: {
|
|
237
|
+
type: 'object',
|
|
238
|
+
properties: {
|
|
239
|
+
session: { type: 'string' },
|
|
240
|
+
until: { type: 'string', description: 'input, the default, waits for something that wants a person; idle waits for the session to go quiet.' },
|
|
241
|
+
timeoutSeconds: { type: 'number', description: '300 by default.' },
|
|
242
|
+
},
|
|
243
|
+
required: ['session'],
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
},
|
|
246
|
+
run: async (host, input) => {
|
|
247
|
+
const stop = text(input.until) || 'input';
|
|
248
|
+
/*
|
|
249
|
+
* Either vocabulary, as `wait.ts` does.
|
|
250
|
+
*
|
|
251
|
+
* A host may rebuild the whole view and send a snapshot, or say what
|
|
252
|
+
* changed - `inputNeeded` and `turnComplete`. Reading only snapshots
|
|
253
|
+
* waits for ever on the second kind.
|
|
254
|
+
*/
|
|
255
|
+
const event = await until(host, uriOf(input), (one) => {
|
|
256
|
+
if (stop === 'idle') {
|
|
257
|
+
if (one.type === 'turnComplete')
|
|
258
|
+
return true;
|
|
259
|
+
return one.type === 'snapshot' && one.active === undefined && one.input === undefined;
|
|
260
|
+
}
|
|
261
|
+
if (one.type === 'inputNeeded')
|
|
262
|
+
return true;
|
|
263
|
+
return one.type === 'snapshot' && one.input !== undefined;
|
|
264
|
+
}, { timeoutSeconds: typeof input.timeoutSeconds === 'number' ? input.timeoutSeconds : 300 });
|
|
265
|
+
if (event === undefined)
|
|
266
|
+
return { state: 'timeout' };
|
|
267
|
+
if (event.type === 'inputNeeded')
|
|
268
|
+
return { state: 'waiting', waitingOn: event.input };
|
|
269
|
+
if (event.type === 'turnComplete')
|
|
270
|
+
return { state: 'idle', finished: said(event.turn) };
|
|
271
|
+
if (event.type !== 'snapshot')
|
|
272
|
+
return { state: 'timeout' };
|
|
273
|
+
return {
|
|
274
|
+
state: event.input === undefined ? 'idle' : 'waiting',
|
|
275
|
+
...(event.input === undefined ? {} : { waitingOn: event.input }),
|
|
276
|
+
...(event.active === undefined ? {} : { running: said(event.active) }),
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'confirm_tool_call',
|
|
282
|
+
title: 'Approve or deny a tool call',
|
|
283
|
+
description: 'Answer a tool call the agent is blocked on. The id comes from wait_for_attention.',
|
|
284
|
+
readOnly: false,
|
|
285
|
+
input: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
session: { type: 'string' },
|
|
289
|
+
toolCallId: { type: 'string' },
|
|
290
|
+
approved: { type: 'boolean', description: 'True to allow it, false to refuse.' },
|
|
291
|
+
optionId: { type: 'string', description: 'One of the options the request offered, where it offered any.' },
|
|
292
|
+
},
|
|
293
|
+
required: ['session', 'toolCallId', 'approved'],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
run: async (host, input) => {
|
|
297
|
+
const option = text(input.optionId);
|
|
298
|
+
host.confirmToolCall(uriOf(input), text(input.toolCallId), input.approved === true, option === '' ? undefined : option);
|
|
299
|
+
return { answered: true };
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'answer_question',
|
|
304
|
+
title: 'Answer what the agent asked',
|
|
305
|
+
description: 'Fill in a question the agent put to a person. The request id and the fields come from wait_for_attention.',
|
|
306
|
+
readOnly: false,
|
|
307
|
+
input: {
|
|
308
|
+
type: 'object',
|
|
309
|
+
properties: {
|
|
310
|
+
session: { type: 'string' },
|
|
311
|
+
requestId: { type: 'string' },
|
|
312
|
+
answers: { type: 'object', description: 'The answers, keyed by question id.', additionalProperties: true },
|
|
313
|
+
reject: { type: 'boolean', description: 'Decline to answer instead.' },
|
|
314
|
+
},
|
|
315
|
+
required: ['session', 'requestId'],
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
},
|
|
318
|
+
run: async (host, input) => {
|
|
319
|
+
const answers = typeof input.answers === 'object' && input.answers !== null
|
|
320
|
+
? input.answers
|
|
321
|
+
: {};
|
|
322
|
+
host.completeInput(uriOf(input), text(input.requestId), input.reject !== true, answers);
|
|
323
|
+
return { answered: true };
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
/** One tool by the name a caller used, or nothing. */
|
|
328
|
+
export const named = (name) => TOOLS.find((one) => one.name === name);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HostConnection, HostEvent } from './ahp/connection.js';
|
|
2
|
+
import type { ModelSelection, SessionUri, Turn } from './ahp/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Watch one session until it does something, then stop watching.
|
|
5
|
+
*
|
|
6
|
+
* Every streaming command is this with a different stopping condition. The
|
|
7
|
+
* subscription is always closed - a caller that left one open would be a
|
|
8
|
+
* process that never exits, which is the one thing a shell cannot work around.
|
|
9
|
+
*/
|
|
10
|
+
export declare function until(host: HostConnection, uri: SessionUri, done: (event: HostEvent) => boolean, options?: {
|
|
11
|
+
onEvent?(event: HostEvent): void;
|
|
12
|
+
timeoutSeconds?: number;
|
|
13
|
+
}): Promise<HostEvent | undefined>;
|
|
14
|
+
/** A turn, as a line of prose rather than a tree of parts. */
|
|
15
|
+
export declare const spoken: (turn: Turn) => string;
|
|
16
|
+
/** What a caller wants told while a turn is running, and how long to wait. */
|
|
17
|
+
export interface TurnOptions {
|
|
18
|
+
model?: ModelSelection;
|
|
19
|
+
timeoutSeconds?: number;
|
|
20
|
+
/** Text the agent has said that the caller has not been given yet. */
|
|
21
|
+
onDelta?(text: string): void;
|
|
22
|
+
/** A tool call the agent is blocked on, said once per call. */
|
|
23
|
+
onWaiting?(call: {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}): void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Say something, and block until the turn it starts has finished.
|
|
30
|
+
*
|
|
31
|
+
* Subscribed before saying anything: the first snapshot is the baseline that
|
|
32
|
+
* says which turns were already there, and one taken afterwards would count
|
|
33
|
+
* the new turn among them.
|
|
34
|
+
*
|
|
35
|
+
* "Finished" is a turn of *ours* having ended - one that was not in the
|
|
36
|
+
* baseline - rather than the last in the list, which is a different session's
|
|
37
|
+
* answer when two people are talking in the same chat. A turn that stops to
|
|
38
|
+
* ask a person something is not finished and is not this caller's to answer,
|
|
39
|
+
* so the wait runs on until whoever is answering has.
|
|
40
|
+
*
|
|
41
|
+
* Answers `undefined` where the timeout ran out, which is a caller's to
|
|
42
|
+
* report rather than to throw: the turn is still going and the session is
|
|
43
|
+
* still there.
|
|
44
|
+
*/
|
|
45
|
+
export declare function turn(host: HostConnection, uri: SessionUri, text: string, options?: TurnOptions): Promise<Turn | undefined>;
|