@butlerbot/sdk 0.0.32 → 0.0.34
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/config.d.ts +19 -0
- package/dist/config.js +24 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +19 -3
- package/dist/link/agent.d.ts +103 -0
- package/dist/link/agent.js +86 -0
- package/dist/link/index.d.ts +3 -1
- package/dist/link/index.js +3 -1
- package/dist/link/link.d.ts +25 -1
- package/dist/link/link.js +78 -2
- package/dist/link/protocol.d.ts +54 -0
- package/dist/modules/transport_link.d.ts +29 -0
- package/dist/modules/transport_link.js +230 -74
- package/package.json +1 -1
- package/readme.md +54 -4
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export declare const CONFIG: {
|
|
2
2
|
server: string;
|
|
3
|
+
/**
|
|
4
|
+
* Where links connect.
|
|
5
|
+
*
|
|
6
|
+
* Not the core server: links are carried by their own service, so the SDK holds two
|
|
7
|
+
* addresses rather than one.
|
|
8
|
+
*/
|
|
9
|
+
link: string;
|
|
3
10
|
healthcheckPath: string;
|
|
4
11
|
paths: {
|
|
5
12
|
conversation: {
|
|
@@ -55,3 +62,15 @@ export declare const CONFIG: {
|
|
|
55
62
|
};
|
|
56
63
|
};
|
|
57
64
|
export type APIPath = keyof typeof CONFIG.paths.conversation;
|
|
65
|
+
/**
|
|
66
|
+
* Which server a link should connect to.
|
|
67
|
+
*
|
|
68
|
+
* `linkUrl` names it outright and always wins. Failing that, a `serverUrl` pointing anywhere
|
|
69
|
+
* other than the hosted core is taken at its word: a self-hosted stack is usually one address,
|
|
70
|
+
* and sending an API key to a host nobody named would be a worse surprise than a wrong path.
|
|
71
|
+
* Otherwise it is the hosted link service, which is what the core server used to be and is not.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveLinkUrl(config: {
|
|
74
|
+
linkUrl?: string;
|
|
75
|
+
serverUrl?: string;
|
|
76
|
+
}): string;
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CONFIG = void 0;
|
|
4
|
+
exports.resolveLinkUrl = resolveLinkUrl;
|
|
4
5
|
exports.CONFIG = {
|
|
5
6
|
server: "https://core.butler.now",
|
|
7
|
+
/**
|
|
8
|
+
* Where links connect.
|
|
9
|
+
*
|
|
10
|
+
* Not the core server: links are carried by their own service, so the SDK holds two
|
|
11
|
+
* addresses rather than one.
|
|
12
|
+
*/
|
|
13
|
+
link: "https://link.butler.now",
|
|
6
14
|
healthcheckPath: "/api/healthcheck",
|
|
7
15
|
paths: {
|
|
8
16
|
conversation: {
|
|
@@ -45,3 +53,19 @@ exports.CONFIG = {
|
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
};
|
|
56
|
+
const withoutTrailingSlash = (url) => url.replace(/\/+$/, "");
|
|
57
|
+
/**
|
|
58
|
+
* Which server a link should connect to.
|
|
59
|
+
*
|
|
60
|
+
* `linkUrl` names it outright and always wins. Failing that, a `serverUrl` pointing anywhere
|
|
61
|
+
* other than the hosted core is taken at its word: a self-hosted stack is usually one address,
|
|
62
|
+
* and sending an API key to a host nobody named would be a worse surprise than a wrong path.
|
|
63
|
+
* Otherwise it is the hosted link service, which is what the core server used to be and is not.
|
|
64
|
+
*/
|
|
65
|
+
function resolveLinkUrl(config) {
|
|
66
|
+
if (config.linkUrl)
|
|
67
|
+
return config.linkUrl;
|
|
68
|
+
if (config.serverUrl && withoutTrailingSlash(config.serverUrl) !== withoutTrailingSlash(exports.CONFIG.server))
|
|
69
|
+
return config.serverUrl;
|
|
70
|
+
return exports.CONFIG.link;
|
|
71
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ type OptionalApiKey<T> = Omit<T, "apiKey"> & {
|
|
|
9
9
|
export type ButlerBotClientOptions = {
|
|
10
10
|
/** The server endpoint, API calls are sent here */
|
|
11
11
|
serverUrl?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Where links connect. Defaults to the hosted link service.
|
|
14
|
+
*
|
|
15
|
+
* Links are no longer carried by the core server, so this is a second address rather than
|
|
16
|
+
* a path on the first. A `serverUrl` pointing at your own stack is used for links too,
|
|
17
|
+
* unless this names somewhere else.
|
|
18
|
+
*/
|
|
19
|
+
linkUrl?: string;
|
|
12
20
|
/** The API key to use with ButlerBot */
|
|
13
21
|
apiKey: string;
|
|
14
22
|
/** Whether to enable debug logs */
|
|
@@ -17,6 +25,7 @@ export type ButlerBotClientOptions = {
|
|
|
17
25
|
export declare class ButlerBotClient {
|
|
18
26
|
private apiKey;
|
|
19
27
|
private serverUrl;
|
|
28
|
+
private linkUrl;
|
|
20
29
|
private debug;
|
|
21
30
|
constructor(config: ButlerBotClientOptions);
|
|
22
31
|
/** Checks the health of the server returning true if server is alive */
|
|
@@ -25,7 +34,10 @@ export declare class ButlerBotClient {
|
|
|
25
34
|
createConversation<V extends APIPath = "v4">(config?: OptionalApiKey<ConversationOptions<V>>): Conversation<V>;
|
|
26
35
|
/**
|
|
27
36
|
* Creates a Link: a live connection that can register tools and hooks, and carry
|
|
28
|
-
* conversations. Inherits the client's API key and
|
|
37
|
+
* conversations. Inherits the client's API key and link URL.
|
|
38
|
+
*
|
|
39
|
+
* The link URL, not the server URL: links are served by their own service. Pass
|
|
40
|
+
* `serverUrl` here, or `linkUrl` to the client, to point somewhere else.
|
|
29
41
|
*/
|
|
30
42
|
createLink(config: OptionalApiKey<LinkOptions>): Link;
|
|
31
43
|
/** Get current usage policy data */
|
|
@@ -33,7 +45,8 @@ export declare class ButlerBotClient {
|
|
|
33
45
|
}
|
|
34
46
|
export * from "./types/type_registry";
|
|
35
47
|
export * from "./link";
|
|
36
|
-
export { Conversation
|
|
48
|
+
export { Conversation };
|
|
49
|
+
export type { ConversationOptions };
|
|
37
50
|
export type { APIPath };
|
|
38
51
|
export type { ConversationStream, ConversationTransport, TransportTurnRequest, TransportHandlers, } from "./modules/transport";
|
|
39
52
|
export { LinkConversationTransport } from "./modules/transport_link";
|
package/dist/index.js
CHANGED
|
@@ -20,10 +20,23 @@ const link_1 = require("./link");
|
|
|
20
20
|
const conversation_1 = require("./modules/conversation");
|
|
21
21
|
Object.defineProperty(exports, "Conversation", { enumerable: true, get: function () { return conversation_1.Conversation; } });
|
|
22
22
|
const usage_1 = require("./modules/usage");
|
|
23
|
+
/**
|
|
24
|
+
* The options a caller actually gave, with the keys they left out removed.
|
|
25
|
+
*
|
|
26
|
+
* A client's own API key and URLs are the fallback for whatever a factory call omits, and the
|
|
27
|
+
* usual way to omit something is to forward an optional setting that happens to be unset —
|
|
28
|
+
* `createLink({ serverUrl: process.env.LINK_URL })`. Spread as it stands, that `undefined`
|
|
29
|
+
* lands on top of the client's resolved value and erases it, so the link ends up at the hosted
|
|
30
|
+
* service rather than the self-hosted stack the client was pointed at. Absent means "not given".
|
|
31
|
+
*/
|
|
32
|
+
function given(config) {
|
|
33
|
+
return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
|
|
34
|
+
}
|
|
23
35
|
class ButlerBotClient {
|
|
24
36
|
constructor(config) {
|
|
25
37
|
this.apiKey = config.apiKey;
|
|
26
38
|
this.serverUrl = config.serverUrl || config_1.CONFIG.server;
|
|
39
|
+
this.linkUrl = (0, config_1.resolveLinkUrl)(config);
|
|
27
40
|
this.debug = config.debug || false;
|
|
28
41
|
}
|
|
29
42
|
/** Checks the health of the server returning true if server is alive */
|
|
@@ -41,14 +54,17 @@ class ButlerBotClient {
|
|
|
41
54
|
}
|
|
42
55
|
/** Spawns a new Conversation, inherits api key and server URL */
|
|
43
56
|
createConversation(config = {}) {
|
|
44
|
-
return new conversation_1.Conversation({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.serverUrl, ...config });
|
|
57
|
+
return new conversation_1.Conversation({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.serverUrl, ...given(config) });
|
|
45
58
|
}
|
|
46
59
|
/**
|
|
47
60
|
* Creates a Link: a live connection that can register tools and hooks, and carry
|
|
48
|
-
* conversations. Inherits the client's API key and
|
|
61
|
+
* conversations. Inherits the client's API key and link URL.
|
|
62
|
+
*
|
|
63
|
+
* The link URL, not the server URL: links are served by their own service. Pass
|
|
64
|
+
* `serverUrl` here, or `linkUrl` to the client, to point somewhere else.
|
|
49
65
|
*/
|
|
50
66
|
createLink(config) {
|
|
51
|
-
return new link_1.Link({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.
|
|
67
|
+
return new link_1.Link({ debug: this.debug, apiKey: this.apiKey, serverUrl: this.linkUrl, ...given(config) });
|
|
52
68
|
}
|
|
53
69
|
/** Get current usage policy data */
|
|
54
70
|
getUsagePolicyData(config) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { LinkAgentDescriptor, LinkServerFrame, LinkServerFrameOf } from "./protocol";
|
|
2
|
+
import { AnyTool } from "./tool";
|
|
3
|
+
export type AgentConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* This agent's id within the link. The public id becomes `link:<linkId>/<id>`,
|
|
6
|
+
* which is what the user's saved settings refer to — so treat it as permanent.
|
|
7
|
+
*/
|
|
8
|
+
id: string;
|
|
9
|
+
/** The agent's own name. It signs its replies with it. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** What Alfred reads when deciding whether to hand something to this agent. */
|
|
12
|
+
description: string;
|
|
13
|
+
/**
|
|
14
|
+
* The agent's system prompt: who it is, what its tools are for, and how they go together.
|
|
15
|
+
* This is where a thousand tools become one coherent worker.
|
|
16
|
+
*/
|
|
17
|
+
prompt: string;
|
|
18
|
+
/**
|
|
19
|
+
* The model the agent runs on, by its catalogue name. Omit it for the user's default.
|
|
20
|
+
* A model the user's plan does not include falls back to their default, exactly as a chat would.
|
|
21
|
+
*/
|
|
22
|
+
model?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The tools the agent works with. They belong to the agent: Alfred never sees them
|
|
25
|
+
* directly, and they need no `addTool` of their own.
|
|
26
|
+
*/
|
|
27
|
+
tools?: AnyTool[];
|
|
28
|
+
/** Shown in Alfred's settings UI. Without it the agent is hidden there. */
|
|
29
|
+
display?: {
|
|
30
|
+
name: string;
|
|
31
|
+
shortDescription: string;
|
|
32
|
+
longDescription: string;
|
|
33
|
+
};
|
|
34
|
+
/** Whether the agent is on before the user has touched it. */
|
|
35
|
+
defaultEnabled?: boolean;
|
|
36
|
+
};
|
|
37
|
+
/** Progress on an exchange, in the words the agent's status feed would show a conversation. */
|
|
38
|
+
export type AgentStatus = {
|
|
39
|
+
label: string;
|
|
40
|
+
state: "running" | "completed" | "failed";
|
|
41
|
+
};
|
|
42
|
+
export type AgentChatOptions = {
|
|
43
|
+
/**
|
|
44
|
+
* The exchange this message continues. Messages on one thread share memory.
|
|
45
|
+
*
|
|
46
|
+
* Defaults to the agent's own thread, which is minted when the `Agent` is created — so one
|
|
47
|
+
* `Agent` remembers across `chat` calls, and a new process starts afresh. Name one yourself
|
|
48
|
+
* to pick a conversation up across restarts, or to keep several going at once.
|
|
49
|
+
*/
|
|
50
|
+
thread?: string;
|
|
51
|
+
/** Called with each status update while the agent works. */
|
|
52
|
+
onStatus?: (status: AgentStatus) => void;
|
|
53
|
+
};
|
|
54
|
+
export type AgentReply = {
|
|
55
|
+
/** What the agent said. */
|
|
56
|
+
text: string;
|
|
57
|
+
/** The thread the reply belongs to, which is what continues it. */
|
|
58
|
+
thread: string;
|
|
59
|
+
};
|
|
60
|
+
/** What an agent needs from its link. Implemented by `Link`. */
|
|
61
|
+
export type AgentRunner = {
|
|
62
|
+
chatAgent(agentId: string, message: string, options: AgentChatOptions): Promise<AgentReply>;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* An AI worker that lives on the server for as long as the link does, working with tools
|
|
66
|
+
* that run here.
|
|
67
|
+
*
|
|
68
|
+
* To Alfred it is one tool: he hands it a task and gets a reply, and the tools behind it
|
|
69
|
+
* stay behind it. To your code it is something to talk to directly — `chat` runs it on the
|
|
70
|
+
* server and hands the answer back here, so a hook callback can ask it to decide something
|
|
71
|
+
* and act on what it says.
|
|
72
|
+
*/
|
|
73
|
+
export declare class Agent {
|
|
74
|
+
private readonly config;
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly tools: AnyTool[];
|
|
77
|
+
/** The public id (`link:<linkId>/<id>`), known once the link has registered it. */
|
|
78
|
+
linkedId?: string;
|
|
79
|
+
/** The thread `chat` uses when not told otherwise. One per `Agent`, so it remembers. */
|
|
80
|
+
thread: string;
|
|
81
|
+
private link?;
|
|
82
|
+
constructor(config: AgentConfig);
|
|
83
|
+
get name(): string;
|
|
84
|
+
get description(): string;
|
|
85
|
+
/** The declaration sent to the server. */
|
|
86
|
+
descriptor(): LinkAgentDescriptor;
|
|
87
|
+
/** Called by `Link.addAgent`. */
|
|
88
|
+
attach(link: AgentRunner): void;
|
|
89
|
+
/** One of this agent's tools, by its id. */
|
|
90
|
+
getTool(id: string): AnyTool | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Asks the agent something and waits for its reply.
|
|
93
|
+
*
|
|
94
|
+
* The run happens on the server, on the user's account; the question and the answer live
|
|
95
|
+
* here. Rejects with a `LinkError` when the agent could not run or failed — never with the
|
|
96
|
+
* agent's own prose, which is a reply like any other.
|
|
97
|
+
*/
|
|
98
|
+
chat(message: string, options?: AgentChatOptions): Promise<AgentReply>;
|
|
99
|
+
/** Starts a fresh thread for the calls that follow, and returns it. */
|
|
100
|
+
newThread(): string;
|
|
101
|
+
}
|
|
102
|
+
/** Whether a server frame ends an `agent.chat` exchange. */
|
|
103
|
+
export declare function isAgentResult(frame: LinkServerFrame): frame is LinkServerFrameOf<"agent.result">;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Agent = void 0;
|
|
4
|
+
exports.isAgentResult = isAgentResult;
|
|
5
|
+
/**
|
|
6
|
+
* An AI worker that lives on the server for as long as the link does, working with tools
|
|
7
|
+
* that run here.
|
|
8
|
+
*
|
|
9
|
+
* To Alfred it is one tool: he hands it a task and gets a reply, and the tools behind it
|
|
10
|
+
* stay behind it. To your code it is something to talk to directly — `chat` runs it on the
|
|
11
|
+
* server and hands the answer back here, so a hook callback can ask it to decide something
|
|
12
|
+
* and act on what it says.
|
|
13
|
+
*/
|
|
14
|
+
class Agent {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.id = config.id;
|
|
18
|
+
this.tools = [...(config.tools ?? [])];
|
|
19
|
+
this.thread = mintThread();
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const tool of this.tools) {
|
|
22
|
+
if (ids.has(tool.id))
|
|
23
|
+
throw new Error(`Agent "${this.id}" holds two tools with the id "${tool.id}".`);
|
|
24
|
+
ids.add(tool.id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
get name() {
|
|
28
|
+
return this.config.name;
|
|
29
|
+
}
|
|
30
|
+
get description() {
|
|
31
|
+
return this.config.description;
|
|
32
|
+
}
|
|
33
|
+
/** The declaration sent to the server. */
|
|
34
|
+
descriptor() {
|
|
35
|
+
return {
|
|
36
|
+
localId: this.id,
|
|
37
|
+
name: this.config.name,
|
|
38
|
+
description: this.config.description,
|
|
39
|
+
prompt: this.config.prompt,
|
|
40
|
+
...(this.config.model ? { model: this.config.model } : {}),
|
|
41
|
+
// An agent's tools are placed by the agent, so whatever platform a tool
|
|
42
|
+
// declared for itself does not travel.
|
|
43
|
+
tools: this.tools.map(tool => {
|
|
44
|
+
const { platforms: _platforms, ...descriptor } = tool.descriptor();
|
|
45
|
+
return descriptor;
|
|
46
|
+
}),
|
|
47
|
+
...(this.config.display ? { display: this.config.display } : {}),
|
|
48
|
+
...(this.config.defaultEnabled !== undefined ? { defaultEnabled: this.config.defaultEnabled } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Called by `Link.addAgent`. */
|
|
52
|
+
attach(link) {
|
|
53
|
+
this.link = link;
|
|
54
|
+
}
|
|
55
|
+
/** One of this agent's tools, by its id. */
|
|
56
|
+
getTool(id) {
|
|
57
|
+
return this.tools.find(tool => tool.id === id);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Asks the agent something and waits for its reply.
|
|
61
|
+
*
|
|
62
|
+
* The run happens on the server, on the user's account; the question and the answer live
|
|
63
|
+
* here. Rejects with a `LinkError` when the agent could not run or failed — never with the
|
|
64
|
+
* agent's own prose, which is a reply like any other.
|
|
65
|
+
*/
|
|
66
|
+
chat(message, options = {}) {
|
|
67
|
+
if (!this.link)
|
|
68
|
+
return Promise.reject(new Error(`Agent "${this.id}" has not been added to a link.`));
|
|
69
|
+
return this.link.chatAgent(this.id, message, { thread: this.thread, ...options });
|
|
70
|
+
}
|
|
71
|
+
/** Starts a fresh thread for the calls that follow, and returns it. */
|
|
72
|
+
newThread() {
|
|
73
|
+
this.thread = mintThread();
|
|
74
|
+
return this.thread;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.Agent = Agent;
|
|
78
|
+
/** A thread id the server will accept: letters, digits, dot, dash, underscore, 64 at most. */
|
|
79
|
+
function mintThread() {
|
|
80
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
81
|
+
return `t-${Date.now().toString(36)}-${random}`;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a server frame ends an `agent.chat` exchange. */
|
|
84
|
+
function isAgentResult(frame) {
|
|
85
|
+
return frame.type === "agent.result";
|
|
86
|
+
}
|
package/dist/link/index.d.ts
CHANGED
|
@@ -2,10 +2,12 @@ export { Link } from "./link";
|
|
|
2
2
|
export type { LinkOptions, LinkEvents, LinkState, ExchangeOptions } from "./link";
|
|
3
3
|
export { Tool } from "./tool";
|
|
4
4
|
export type { AnyTool, ToolConfig, ToolRunContext, ToolCallMeta, ToolStatusReporter, ToolInvocation } from "./tool";
|
|
5
|
+
export { Agent } from "./agent";
|
|
6
|
+
export type { AgentConfig, AgentChatOptions, AgentReply, AgentStatus, AgentRunner } from "./agent";
|
|
5
7
|
export { Hook } from "./hook";
|
|
6
8
|
export type { AnyHook, HookConfig, HookEmitter } from "./hook";
|
|
7
9
|
export { LINK_PROTOCOL_VERSION, LinkError } from "./protocol";
|
|
8
|
-
export type { LinkClientFrame, LinkClientFrameOf, LinkClientFrameType, LinkServerFrame, LinkServerFrameOf, LinkServerFrameType, LinkScopeKind, LinkToolDescriptor, LinkHookDeclaration, LinkHookEventDeclaration, } from "./protocol";
|
|
10
|
+
export type { LinkClientFrame, LinkClientFrameOf, LinkClientFrameType, LinkServerFrame, LinkServerFrameOf, LinkServerFrameType, LinkScopeKind, LinkToolDescriptor, LinkAgentDescriptor, LinkHookDeclaration, LinkHookEventDeclaration, } from "./protocol";
|
|
9
11
|
export { SubscriptionStore } from "./subscriptions";
|
|
10
12
|
export type { LinkSubscription, SubscriptionSnapshot, SubscriptionDelta } from "./subscriptions";
|
|
11
13
|
export { matchesPrefilter, readPath } from "./prefilter";
|
package/dist/link/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.defaultSocketFactory = exports.buildHandshake = exports.readPath = exports.matchesPrefilter = exports.SubscriptionStore = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Tool = exports.Link = void 0;
|
|
3
|
+
exports.defaultSocketFactory = exports.buildHandshake = exports.readPath = exports.matchesPrefilter = exports.SubscriptionStore = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Agent = exports.Tool = exports.Link = void 0;
|
|
4
4
|
var link_1 = require("./link");
|
|
5
5
|
Object.defineProperty(exports, "Link", { enumerable: true, get: function () { return link_1.Link; } });
|
|
6
6
|
var tool_1 = require("./tool");
|
|
7
7
|
Object.defineProperty(exports, "Tool", { enumerable: true, get: function () { return tool_1.Tool; } });
|
|
8
|
+
var agent_1 = require("./agent");
|
|
9
|
+
Object.defineProperty(exports, "Agent", { enumerable: true, get: function () { return agent_1.Agent; } });
|
|
8
10
|
var hook_1 = require("./hook");
|
|
9
11
|
Object.defineProperty(exports, "Hook", { enumerable: true, get: function () { return hook_1.Hook; } });
|
|
10
12
|
var protocol_1 = require("./protocol");
|
package/dist/link/link.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Agent, AgentChatOptions, AgentReply, AgentRunner } from "./agent";
|
|
1
2
|
import { AnyHook } from "./hook";
|
|
2
3
|
import { LinkClientFrameType, LinkClientPayloads, LinkScopeKind, LinkServerFrame } from "./protocol";
|
|
3
4
|
import { SocketFactory } from "./socket";
|
|
@@ -48,6 +49,7 @@ export type LinkOptions = {
|
|
|
48
49
|
* one takes over and the older one's registrations are released.
|
|
49
50
|
*/
|
|
50
51
|
linkId: string;
|
|
52
|
+
/** The link service. Defaults to the hosted one, which is not the core server. */
|
|
51
53
|
serverUrl?: string;
|
|
52
54
|
/** Informational, shown in server logs. Defaults to the SDK name. */
|
|
53
55
|
client?: string;
|
|
@@ -83,10 +85,11 @@ export type ExchangeOptions = {
|
|
|
83
85
|
* re-declared on connect, and ids are derived from your `linkId`, so a reconnect
|
|
84
86
|
* anywhere lands on the same saved settings and subscriptions.
|
|
85
87
|
*/
|
|
86
|
-
export declare class Link {
|
|
88
|
+
export declare class Link implements AgentRunner {
|
|
87
89
|
private readonly options;
|
|
88
90
|
private readonly emitter;
|
|
89
91
|
private readonly tools;
|
|
92
|
+
private readonly agents;
|
|
90
93
|
private readonly hooks;
|
|
91
94
|
private readonly subscriptionStore;
|
|
92
95
|
private readonly pending;
|
|
@@ -129,10 +132,22 @@ export declare class Link {
|
|
|
129
132
|
constructor(options: LinkOptions);
|
|
130
133
|
/** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
|
|
131
134
|
addTool(tool: AnyTool): this;
|
|
135
|
+
/**
|
|
136
|
+
* Adds an agent, with the tools it works with.
|
|
137
|
+
*
|
|
138
|
+
* The tools come with the agent — they need no `addTool` of their own, and giving them one
|
|
139
|
+
* would place them in the chat as well, which is the thing an agent exists to avoid. One
|
|
140
|
+
* namespace for everything the link declares, so an id used twice is refused here rather
|
|
141
|
+
* than resolved by whichever registered last.
|
|
142
|
+
*/
|
|
143
|
+
addAgent(agent: Agent): this;
|
|
132
144
|
/** Adds a hook that can wake the user's background agents. */
|
|
133
145
|
addHook(hook: AnyHook): this;
|
|
134
146
|
getTool(id: string): AnyTool | undefined;
|
|
135
147
|
getHook(id: string): AnyHook | undefined;
|
|
148
|
+
getAgent(id: string): Agent | undefined;
|
|
149
|
+
/** A tool by its local id, wherever it lives: on the link itself or behind one of its agents. */
|
|
150
|
+
private findTool;
|
|
136
151
|
get state(): LinkState;
|
|
137
152
|
get linkId(): string;
|
|
138
153
|
/** The ephemeral id of this connection. Changes on every reconnect. */
|
|
@@ -244,6 +259,15 @@ export declare class Link {
|
|
|
244
259
|
private wait;
|
|
245
260
|
private settleWaiters;
|
|
246
261
|
private registerAll;
|
|
262
|
+
private registerAgents;
|
|
263
|
+
/**
|
|
264
|
+
* Called by `Agent.chat`.
|
|
265
|
+
*
|
|
266
|
+
* One exchange, however long the agent takes: status frames go to the caller as they
|
|
267
|
+
* arrive, and the result frame ends it. Never timed out here — an agent that is calling
|
|
268
|
+
* tools on this very machine may legitimately take a while.
|
|
269
|
+
*/
|
|
270
|
+
chatAgent(agentId: string, message: string, options: AgentChatOptions): Promise<AgentReply>;
|
|
247
271
|
private registerTools;
|
|
248
272
|
private registerHook;
|
|
249
273
|
/**
|
package/dist/link/link.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Link = void 0;
|
|
4
4
|
const config_1 = require("../config");
|
|
5
5
|
const emitter_1 = require("../util/emitter");
|
|
6
|
+
const agent_1 = require("./agent");
|
|
6
7
|
const protocol_1 = require("./protocol");
|
|
7
8
|
const socket_1 = require("./socket");
|
|
8
9
|
const subscriptions_1 = require("./subscriptions");
|
|
@@ -23,6 +24,7 @@ class Link {
|
|
|
23
24
|
constructor(options) {
|
|
24
25
|
this.emitter = new emitter_1.Emitter();
|
|
25
26
|
this.tools = new Map();
|
|
27
|
+
this.agents = new Map();
|
|
26
28
|
this.hooks = new Map();
|
|
27
29
|
this.subscriptionStore = new subscriptions_1.SubscriptionStore();
|
|
28
30
|
this.pending = new Map();
|
|
@@ -64,7 +66,7 @@ class Link {
|
|
|
64
66
|
reconnect: true,
|
|
65
67
|
debug: false,
|
|
66
68
|
client: "@butlerbot/sdk",
|
|
67
|
-
serverUrl: config_1.CONFIG.
|
|
69
|
+
serverUrl: config_1.CONFIG.link,
|
|
68
70
|
socketFactory: socket_1.defaultSocketFactory,
|
|
69
71
|
...stripUndefined(options),
|
|
70
72
|
};
|
|
@@ -93,6 +95,29 @@ class Link {
|
|
|
93
95
|
void this.registerTools([tool]);
|
|
94
96
|
return this;
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Adds an agent, with the tools it works with.
|
|
100
|
+
*
|
|
101
|
+
* The tools come with the agent — they need no `addTool` of their own, and giving them one
|
|
102
|
+
* would place them in the chat as well, which is the thing an agent exists to avoid. One
|
|
103
|
+
* namespace for everything the link declares, so an id used twice is refused here rather
|
|
104
|
+
* than resolved by whichever registered last.
|
|
105
|
+
*/
|
|
106
|
+
addAgent(agent) {
|
|
107
|
+
for (const tool of agent.tools) {
|
|
108
|
+
const holder = this.findTool(tool.id) ? "another tool" : this.agents.has(tool.id) ? "an agent" : undefined;
|
|
109
|
+
if (holder)
|
|
110
|
+
throw new Error(`Cannot add agent "${agent.id}": its tool "${tool.id}" shares an id with ${holder} on this link.`);
|
|
111
|
+
}
|
|
112
|
+
if (this.tools.has(agent.id) || this.findTool(agent.id)) {
|
|
113
|
+
throw new Error(`Cannot add agent "${agent.id}": a tool on this link already has that id.`);
|
|
114
|
+
}
|
|
115
|
+
this.agents.set(agent.id, agent);
|
|
116
|
+
agent.attach(this);
|
|
117
|
+
if (this.currentState === "open")
|
|
118
|
+
void this.registerAgents([agent]);
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
96
121
|
/** Adds a hook that can wake the user's background agents. */
|
|
97
122
|
addHook(hook) {
|
|
98
123
|
this.hooks.set(hook.id, hook);
|
|
@@ -107,6 +132,21 @@ class Link {
|
|
|
107
132
|
getHook(id) {
|
|
108
133
|
return this.hooks.get(id);
|
|
109
134
|
}
|
|
135
|
+
getAgent(id) {
|
|
136
|
+
return this.agents.get(id);
|
|
137
|
+
}
|
|
138
|
+
/** A tool by its local id, wherever it lives: on the link itself or behind one of its agents. */
|
|
139
|
+
findTool(localId) {
|
|
140
|
+
const own = this.tools.get(localId);
|
|
141
|
+
if (own)
|
|
142
|
+
return own;
|
|
143
|
+
for (const agent of this.agents.values()) {
|
|
144
|
+
const tool = agent.getTool(localId);
|
|
145
|
+
if (tool)
|
|
146
|
+
return tool;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
110
150
|
// =============================================
|
|
111
151
|
// STATE
|
|
112
152
|
// =============================================
|
|
@@ -516,9 +556,45 @@ class Link {
|
|
|
516
556
|
// =============================================
|
|
517
557
|
async registerAll() {
|
|
518
558
|
await this.registerTools(Array.from(this.tools.values()));
|
|
559
|
+
await this.registerAgents(Array.from(this.agents.values()));
|
|
519
560
|
for (const hook of this.hooks.values())
|
|
520
561
|
await this.registerHook(hook);
|
|
521
562
|
}
|
|
563
|
+
async registerAgents(agents) {
|
|
564
|
+
if (!agents.length)
|
|
565
|
+
return;
|
|
566
|
+
const frame = await this.exchange("agent.register", { agents: agents.map(agent => agent.descriptor()) }, { awaitReady: false });
|
|
567
|
+
const ids = frame.payload.ids ?? [];
|
|
568
|
+
agents.forEach((agent, index) => { agent.linkedId = ids[index]; });
|
|
569
|
+
this.debug(`registered ${agents.length} agent(s)`);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Called by `Agent.chat`.
|
|
573
|
+
*
|
|
574
|
+
* One exchange, however long the agent takes: status frames go to the caller as they
|
|
575
|
+
* arrive, and the result frame ends it. Never timed out here — an agent that is calling
|
|
576
|
+
* tools on this very machine may legitimately take a while.
|
|
577
|
+
*/
|
|
578
|
+
async chatAgent(agentId, message, options) {
|
|
579
|
+
const frame = await this.exchange("agent.chat", {
|
|
580
|
+
localId: agentId,
|
|
581
|
+
message,
|
|
582
|
+
...(options.thread ? { thread: options.thread } : {}),
|
|
583
|
+
}, {
|
|
584
|
+
timeoutMs: 0,
|
|
585
|
+
isDone: agent_1.isAgentResult,
|
|
586
|
+
onFrame: (update) => {
|
|
587
|
+
if (update.type === "agent.status")
|
|
588
|
+
options.onStatus?.({ label: update.payload.label, state: update.payload.state });
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
if (!(0, agent_1.isAgentResult)(frame))
|
|
592
|
+
throw new protocol_1.LinkError("bad_frame", `Expected an agent.result, got "${frame.type}".`);
|
|
593
|
+
const { payload } = frame;
|
|
594
|
+
if (!payload.ok)
|
|
595
|
+
throw new protocol_1.LinkError(payload.code, payload.error);
|
|
596
|
+
return { text: payload.output, thread: payload.thread };
|
|
597
|
+
}
|
|
522
598
|
async registerTools(tools) {
|
|
523
599
|
if (!tools.length)
|
|
524
600
|
return;
|
|
@@ -725,7 +801,7 @@ class Link {
|
|
|
725
801
|
}
|
|
726
802
|
handleToolCall(frame) {
|
|
727
803
|
const { callId, localId, args, meta } = frame.payload;
|
|
728
|
-
const tool = this.
|
|
804
|
+
const tool = this.findTool(localId);
|
|
729
805
|
if (!tool) {
|
|
730
806
|
this.send("tool.result", { ok: false, error: `This link has no tool "${localId}".` }, frame.id);
|
|
731
807
|
return;
|
package/dist/link/protocol.d.ts
CHANGED
|
@@ -24,6 +24,22 @@ export type LinkToolDescriptor = {
|
|
|
24
24
|
platforms?: string[];
|
|
25
25
|
timeoutMs?: number;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* An agent as declared to the server: a prompt, a model and the tools it works with.
|
|
29
|
+
*
|
|
30
|
+
* The tools travel inside the agent because that is what places them: reachable through the
|
|
31
|
+
* agent only, never from a chat directly.
|
|
32
|
+
*/
|
|
33
|
+
export type LinkAgentDescriptor = {
|
|
34
|
+
localId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
prompt: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
tools: Omit<LinkToolDescriptor, "platforms">[];
|
|
40
|
+
display?: LinkToolDescriptor["display"];
|
|
41
|
+
defaultEnabled?: boolean;
|
|
42
|
+
};
|
|
27
43
|
export type LinkHookEventDeclaration = {
|
|
28
44
|
name: string;
|
|
29
45
|
description?: string;
|
|
@@ -139,6 +155,18 @@ export type LinkClientPayloads = {
|
|
|
139
155
|
chatId: string;
|
|
140
156
|
message: string;
|
|
141
157
|
};
|
|
158
|
+
"agent.register": {
|
|
159
|
+
agents: LinkAgentDescriptor[];
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Talks to one of this link's agents directly. `thread` names the exchange the message
|
|
163
|
+
* continues; the same thread carries the same memory.
|
|
164
|
+
*/
|
|
165
|
+
"agent.chat": {
|
|
166
|
+
localId: string;
|
|
167
|
+
message: string;
|
|
168
|
+
thread?: string;
|
|
169
|
+
};
|
|
142
170
|
};
|
|
143
171
|
export type LinkClientFrameType = keyof LinkClientPayloads;
|
|
144
172
|
/**
|
|
@@ -208,12 +236,19 @@ export type LinkServerPayloads = {
|
|
|
208
236
|
chatId?: string;
|
|
209
237
|
message: string;
|
|
210
238
|
};
|
|
239
|
+
/**
|
|
240
|
+
* A turn ended, one way or another.
|
|
241
|
+
*
|
|
242
|
+
* `lastEventId` comes with `turn_suspended`: the turn is still being answered somewhere
|
|
243
|
+
* and this is where a `conversation.attach` should pick it back up.
|
|
244
|
+
*/
|
|
211
245
|
"conversation.done": {
|
|
212
246
|
chatId?: string;
|
|
213
247
|
ok: boolean;
|
|
214
248
|
code?: string;
|
|
215
249
|
error?: string;
|
|
216
250
|
message?: string;
|
|
251
|
+
lastEventId?: string;
|
|
217
252
|
};
|
|
218
253
|
/** A turn was stopped. `mode` is what was applied, `requestedMode` what was asked for. */
|
|
219
254
|
"conversation.stopped": {
|
|
@@ -230,6 +265,25 @@ export type LinkServerPayloads = {
|
|
|
230
265
|
reason: string;
|
|
231
266
|
reconnectAfterMs: number;
|
|
232
267
|
};
|
|
268
|
+
/** Progress on an `agent.chat`, in the words the agent's status feed would show a conversation. */
|
|
269
|
+
"agent.status": {
|
|
270
|
+
localId: string;
|
|
271
|
+
label: string;
|
|
272
|
+
state: "running" | "completed" | "failed";
|
|
273
|
+
};
|
|
274
|
+
/** The agent's reply, exactly once per `agent.chat`. */
|
|
275
|
+
"agent.result": {
|
|
276
|
+
localId: string;
|
|
277
|
+
thread: string;
|
|
278
|
+
ok: true;
|
|
279
|
+
output: string;
|
|
280
|
+
} | {
|
|
281
|
+
localId: string;
|
|
282
|
+
thread?: string;
|
|
283
|
+
ok: false;
|
|
284
|
+
code: string;
|
|
285
|
+
error: string;
|
|
286
|
+
};
|
|
233
287
|
/**
|
|
234
288
|
* The full set this connection should watch, for the sources it has registered.
|
|
235
289
|
*
|
|
@@ -50,6 +50,35 @@ export declare class LinkConversationTransport implements ConversationTransport
|
|
|
50
50
|
*/
|
|
51
51
|
attach(request: TransportAttachRequest, handlers: TransportHandlers): ConversationStream;
|
|
52
52
|
private runTurn;
|
|
53
|
+
/**
|
|
54
|
+
* One `conversation.attach`: streams a turn's events to the caller and resolves with how
|
|
55
|
+
* that watch ended.
|
|
56
|
+
*
|
|
57
|
+
* Shared by watching somebody else's turn and by rejoining one of our own, because from
|
|
58
|
+
* here the two are the same act. `progress` is carried rather than returned: a watch that
|
|
59
|
+
* dies halfway still has to leave behind where it got to, or a resume would replay from
|
|
60
|
+
* the beginning and the caller would read the answer twice.
|
|
61
|
+
*/
|
|
62
|
+
private watch;
|
|
63
|
+
/**
|
|
64
|
+
* Picks a turn back up after losing sight of it.
|
|
65
|
+
*
|
|
66
|
+
* Two ways to lose one, one way to get it back. The socket can go — a deploy of the link
|
|
67
|
+
* service, a proxy timing out — which rejects the exchange carrying the turn. Or core can
|
|
68
|
+
* suspend the turn at its own deploy and hand it to another instance, which the link
|
|
69
|
+
* service follows for two minutes before giving up and saying `turn_suspended`. Either way
|
|
70
|
+
* the turn is still being answered and the conversation still holds it, so this re-attaches
|
|
71
|
+
* from the last event the caller was actually given and the stream reads as one answer.
|
|
72
|
+
*
|
|
73
|
+
* `no_active_turn` is the ambiguous reply and it is deliberately not treated as an ending:
|
|
74
|
+
* during a handover it means "not picked up yet" far more often than it means "gone", and
|
|
75
|
+
* the window is what decides between them.
|
|
76
|
+
*
|
|
77
|
+
* Nothing here throws. It runs behind a stream the caller already holds, so the outcomes
|
|
78
|
+
* that matter are the ones delivered into it: a completion, or a failure that says plainly
|
|
79
|
+
* that the answer was lost track of rather than that it failed.
|
|
80
|
+
*/
|
|
81
|
+
private resume;
|
|
53
82
|
/** Opens a session, or reuses the open one when it is for the same conversation. */
|
|
54
83
|
private session;
|
|
55
84
|
}
|
|
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.LinkConversationTransport = void 0;
|
|
4
4
|
const protocol_1 = require("../link/protocol");
|
|
5
5
|
const transport_1 = require("./transport");
|
|
6
|
+
/**
|
|
7
|
+
* How long to keep trying to pick a lost turn back up before giving up on it.
|
|
8
|
+
*
|
|
9
|
+
* A turn belongs to its conversation, not to the socket that asked for one, so neither losing
|
|
10
|
+
* the connection nor losing the instance answering it — which is what a deploy does — ends it.
|
|
11
|
+
* Long enough to outlast a deploy of either service, short enough that a caller awaiting a
|
|
12
|
+
* reply is not left there forever when the turn really is gone.
|
|
13
|
+
*/
|
|
14
|
+
const RESUME_WINDOW_MS = 60000;
|
|
15
|
+
/** Waits between attempts to pick a turn back up. The last value repeats. */
|
|
16
|
+
const RESUME_BACKOFF_MS = [250, 500, 1000, 2000, 4000, 5000];
|
|
6
17
|
/**
|
|
7
18
|
* Carries a turn over an existing Link connection.
|
|
8
19
|
*
|
|
@@ -18,20 +29,20 @@ class LinkConversationTransport {
|
|
|
18
29
|
link.on("disconnect", () => { this.sessionId = undefined; });
|
|
19
30
|
}
|
|
20
31
|
send(request, handlers) {
|
|
21
|
-
|
|
32
|
+
const listening = { closed: false };
|
|
22
33
|
const deliver = {
|
|
23
|
-
payload: (payload) => { if (!closed)
|
|
34
|
+
payload: (payload) => { if (!listening.closed)
|
|
24
35
|
handlers.payload(payload); },
|
|
25
|
-
convoId: (convoId) => { if (!closed)
|
|
36
|
+
convoId: (convoId) => { if (!listening.closed)
|
|
26
37
|
handlers.convoId(convoId); },
|
|
27
38
|
};
|
|
28
|
-
void this.runTurn(request, deliver, true).catch((error) => {
|
|
39
|
+
void this.runTurn(request, deliver, true, listening).catch((error) => {
|
|
29
40
|
const failure = error instanceof protocol_1.LinkError
|
|
30
41
|
? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
|
|
31
42
|
: (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId);
|
|
32
43
|
deliver.payload(failure);
|
|
33
44
|
});
|
|
34
|
-
return { close: () => { closed = true; } };
|
|
45
|
+
return { close: () => { listening.closed = true; } };
|
|
35
46
|
}
|
|
36
47
|
/**
|
|
37
48
|
* Stops a running turn.
|
|
@@ -96,57 +107,46 @@ class LinkConversationTransport {
|
|
|
96
107
|
* ordinary answer for one that is simply idle.
|
|
97
108
|
*/
|
|
98
109
|
attach(request, handlers) {
|
|
99
|
-
|
|
100
|
-
const deliver = (payload) => { if (!closed)
|
|
110
|
+
const listening = { closed: false };
|
|
111
|
+
const deliver = (payload) => { if (!listening.closed)
|
|
101
112
|
handlers.payload(payload); };
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
timeoutMs: 0,
|
|
108
|
-
isDone: (frame) => frame.type === "conversation.done",
|
|
109
|
-
onFrame: (frame) => {
|
|
110
|
-
if (frame.type === "conversation.event") {
|
|
111
|
-
const payload = frame.payload;
|
|
112
|
-
const event = payload.event;
|
|
113
|
-
const final = event.type === "response_status" && Boolean(event.payload?.completed);
|
|
114
|
-
deliver({
|
|
115
|
-
success: true,
|
|
116
|
-
data: {
|
|
117
|
-
response: event,
|
|
118
|
-
convoId: payload.chatId ?? request.chatId,
|
|
119
|
-
...(final ? { quitStream: true } : {}),
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
if (frame.type === "conversation.notice") {
|
|
125
|
-
deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? request.chatId));
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
void watching.then((done) => {
|
|
130
|
-
const payload = done.payload;
|
|
113
|
+
const progress = { chatId: request.chatId, lastEventId: request.afterEventId, sawCompletion: false };
|
|
114
|
+
// Watching is allowed to end in silence, which is why `quietWhenGone` is true here and
|
|
115
|
+
// false for a turn of our own: nobody is waiting on an answer to a question they asked.
|
|
116
|
+
const resume = () => this.resume(request.chatId, progress, deliver, listening, { quietWhenGone: true });
|
|
117
|
+
void this.watch(request.chatId, progress, deliver).then(async (payload) => {
|
|
131
118
|
if (payload.ok)
|
|
132
119
|
return;
|
|
133
120
|
// Nothing running is not a failure: the caller asked to watch a conversation
|
|
134
121
|
// that has nothing to watch, and the stream simply ends.
|
|
135
122
|
if (payload.code === "no_active_turn")
|
|
136
123
|
return;
|
|
124
|
+
// The turn is alive, somewhere this connection can no longer see. Following it is
|
|
125
|
+
// the entire point of being here.
|
|
126
|
+
if (payload.code === "turn_suspended") {
|
|
127
|
+
if (payload.lastEventId)
|
|
128
|
+
progress.lastEventId = payload.lastEventId;
|
|
129
|
+
await resume();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
137
132
|
deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be watched.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? request.chatId));
|
|
138
|
-
}, (error) => {
|
|
133
|
+
}, async (error) => {
|
|
139
134
|
if (error instanceof protocol_1.LinkError && error.code === "no_active_turn")
|
|
140
135
|
return;
|
|
136
|
+
// The socket went while we were watching. The turn did not go with it.
|
|
137
|
+
if (error instanceof protocol_1.LinkError && error.code === "disconnected") {
|
|
138
|
+
await resume();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
141
|
deliver(error instanceof protocol_1.LinkError
|
|
142
142
|
? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
|
|
143
143
|
: (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId));
|
|
144
144
|
});
|
|
145
145
|
return {
|
|
146
146
|
close: () => {
|
|
147
|
-
if (closed)
|
|
147
|
+
if (listening.closed)
|
|
148
148
|
return;
|
|
149
|
-
closed = true;
|
|
149
|
+
listening.closed = true;
|
|
150
150
|
// Best-effort: a socket that has gone has already ended the watch for us.
|
|
151
151
|
try {
|
|
152
152
|
this.link.send("conversation.detach", { chatId: request.chatId });
|
|
@@ -157,15 +157,14 @@ class LinkConversationTransport {
|
|
|
157
157
|
},
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
|
-
async runTurn(request, handlers, mayRetry) {
|
|
160
|
+
async runTurn(request, handlers, mayRetry, listening) {
|
|
161
161
|
const sessionId = await this.session(request);
|
|
162
|
-
|
|
162
|
+
const progress = { chatId: request.chatId ?? this.sessionChatId, sawCompletion: false };
|
|
163
163
|
let announcedChatId = false;
|
|
164
|
-
let sawCompletion = false;
|
|
165
164
|
const learnChatId = (candidate) => {
|
|
166
165
|
if (!candidate)
|
|
167
166
|
return;
|
|
168
|
-
chatId = candidate;
|
|
167
|
+
progress.chatId = candidate;
|
|
169
168
|
this.sessionChatId = candidate;
|
|
170
169
|
handlers.convoId(candidate);
|
|
171
170
|
// Mirrors the HTTP transport's first byte, which tells a client which
|
|
@@ -176,13 +175,99 @@ class LinkConversationTransport {
|
|
|
176
175
|
handlers.payload((0, transport_1.convoStartedPayload)(candidate));
|
|
177
176
|
}
|
|
178
177
|
};
|
|
179
|
-
learnChatId(chatId);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
learnChatId(progress.chatId);
|
|
179
|
+
let done;
|
|
180
|
+
try {
|
|
181
|
+
done = await this.link.exchange("conversation.chat", {
|
|
182
|
+
sessionId,
|
|
183
|
+
message: request.message,
|
|
184
|
+
...(request.model ? { model: request.model } : {}),
|
|
185
|
+
...(request.instructions ? { instructions: request.instructions } : {}),
|
|
186
|
+
...(request.personality ? { personality: request.personality } : {}),
|
|
187
|
+
}, {
|
|
188
|
+
// A turn takes as long as it takes; only the transport dying ends it early.
|
|
189
|
+
timeoutMs: 0,
|
|
190
|
+
isDone: (frame) => frame.type === "conversation.done",
|
|
191
|
+
onFrame: (frame) => {
|
|
192
|
+
if (frame.type === "conversation.event") {
|
|
193
|
+
const payload = frame.payload;
|
|
194
|
+
learnChatId(payload.chatId);
|
|
195
|
+
if (payload.eventId)
|
|
196
|
+
progress.lastEventId = payload.eventId;
|
|
197
|
+
const event = payload.event;
|
|
198
|
+
const final = event.type === "response_status" && Boolean(event.payload?.completed);
|
|
199
|
+
if (final)
|
|
200
|
+
progress.sawCompletion = true;
|
|
201
|
+
handlers.payload({
|
|
202
|
+
success: true,
|
|
203
|
+
data: {
|
|
204
|
+
response: event,
|
|
205
|
+
...(payload.chatId ?? progress.chatId ? { convoId: payload.chatId ?? progress.chatId } : {}),
|
|
206
|
+
...(final ? { quitStream: true } : {}),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (frame.type === "conversation.notice") {
|
|
212
|
+
const payload = frame.payload;
|
|
213
|
+
learnChatId(payload.chatId);
|
|
214
|
+
handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? progress.chatId));
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// The socket went while the turn was running. The turn did not go with it: it
|
|
221
|
+
// belongs to the conversation, and the conversation outlives this connection.
|
|
222
|
+
if (progress.chatId && error instanceof protocol_1.LinkError && error.code === "disconnected") {
|
|
223
|
+
await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
const payload = done.payload;
|
|
229
|
+
learnChatId(payload.chatId);
|
|
230
|
+
if (payload.ok) {
|
|
231
|
+
// Nearly always the pipeline's own completion event has already closed the
|
|
232
|
+
// stream; this is for the turn that ended without one.
|
|
233
|
+
if (!progress.sawCompletion)
|
|
234
|
+
handlers.payload((0, transport_1.completedPayload)(payload.chatId ?? progress.chatId));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// The session died with a connection we have since replaced. Reopening it is
|
|
238
|
+
// invisible to the caller, and the message has not been delivered yet.
|
|
239
|
+
if (payload.code === "unknown_session" && mayRetry) {
|
|
240
|
+
this.sessionId = undefined;
|
|
241
|
+
await this.runTurn(request, handlers, false, listening);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Core suspended the turn at a deploy and handed it to another instance, and the link
|
|
245
|
+
// service followed it as far as it could. The answer is still being written; rejoining
|
|
246
|
+
// it is the difference between a deploy costing a reply and costing nothing.
|
|
247
|
+
if (payload.code === "turn_suspended" && progress.chatId) {
|
|
248
|
+
if (payload.lastEventId)
|
|
249
|
+
progress.lastEventId = payload.lastEventId;
|
|
250
|
+
await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
handlers.payload((0, transport_1.failurePayload)(
|
|
254
|
+
// `turn_failed` means the dialogue itself failed, and then `error` holds the
|
|
255
|
+
// code the HTTP transport would have reported.
|
|
256
|
+
payload.code === "turn_failed" ? payload.error ?? payload.code : payload.code ?? "link_error", payload.error ?? "The turn failed.", payload.message ?? payload.error ?? "I'm afraid that turn could not be completed.", payload.chatId ?? progress.chatId));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* One `conversation.attach`: streams a turn's events to the caller and resolves with how
|
|
260
|
+
* that watch ended.
|
|
261
|
+
*
|
|
262
|
+
* Shared by watching somebody else's turn and by rejoining one of our own, because from
|
|
263
|
+
* here the two are the same act. `progress` is carried rather than returned: a watch that
|
|
264
|
+
* dies halfway still has to leave behind where it got to, or a resume would replay from
|
|
265
|
+
* the beginning and the caller would read the answer twice.
|
|
266
|
+
*/
|
|
267
|
+
async watch(chatId, progress, deliver) {
|
|
268
|
+
const done = await this.link.exchange("conversation.attach", {
|
|
269
|
+
chatId,
|
|
270
|
+
...(progress.lastEventId ? { afterEventId: progress.lastEventId } : {}),
|
|
186
271
|
}, {
|
|
187
272
|
// A turn takes as long as it takes; only the transport dying ends it early.
|
|
188
273
|
timeoutMs: 0,
|
|
@@ -190,48 +275,112 @@ class LinkConversationTransport {
|
|
|
190
275
|
onFrame: (frame) => {
|
|
191
276
|
if (frame.type === "conversation.event") {
|
|
192
277
|
const payload = frame.payload;
|
|
193
|
-
|
|
278
|
+
if (payload.eventId)
|
|
279
|
+
progress.lastEventId = payload.eventId;
|
|
194
280
|
const event = payload.event;
|
|
195
281
|
const final = event.type === "response_status" && Boolean(event.payload?.completed);
|
|
196
282
|
if (final)
|
|
197
|
-
sawCompletion = true;
|
|
198
|
-
|
|
283
|
+
progress.sawCompletion = true;
|
|
284
|
+
deliver({
|
|
199
285
|
success: true,
|
|
200
286
|
data: {
|
|
201
287
|
response: event,
|
|
202
|
-
|
|
288
|
+
convoId: payload.chatId ?? chatId,
|
|
203
289
|
...(final ? { quitStream: true } : {}),
|
|
204
290
|
},
|
|
205
291
|
});
|
|
206
292
|
return;
|
|
207
293
|
}
|
|
208
294
|
if (frame.type === "conversation.notice") {
|
|
209
|
-
|
|
210
|
-
learnChatId(payload.chatId);
|
|
211
|
-
handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? chatId));
|
|
295
|
+
deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? chatId));
|
|
212
296
|
}
|
|
213
297
|
},
|
|
214
298
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
299
|
+
return done.payload;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Picks a turn back up after losing sight of it.
|
|
303
|
+
*
|
|
304
|
+
* Two ways to lose one, one way to get it back. The socket can go — a deploy of the link
|
|
305
|
+
* service, a proxy timing out — which rejects the exchange carrying the turn. Or core can
|
|
306
|
+
* suspend the turn at its own deploy and hand it to another instance, which the link
|
|
307
|
+
* service follows for two minutes before giving up and saying `turn_suspended`. Either way
|
|
308
|
+
* the turn is still being answered and the conversation still holds it, so this re-attaches
|
|
309
|
+
* from the last event the caller was actually given and the stream reads as one answer.
|
|
310
|
+
*
|
|
311
|
+
* `no_active_turn` is the ambiguous reply and it is deliberately not treated as an ending:
|
|
312
|
+
* during a handover it means "not picked up yet" far more often than it means "gone", and
|
|
313
|
+
* the window is what decides between them.
|
|
314
|
+
*
|
|
315
|
+
* Nothing here throws. It runs behind a stream the caller already holds, so the outcomes
|
|
316
|
+
* that matter are the ones delivered into it: a completion, or a failure that says plainly
|
|
317
|
+
* that the answer was lost track of rather than that it failed.
|
|
318
|
+
*/
|
|
319
|
+
async resume(chatId, progress, deliver, listening, options) {
|
|
320
|
+
const deadline = Date.now() + RESUME_WINDOW_MS;
|
|
321
|
+
for (let attempt = 0; !listening.closed && Date.now() < deadline; attempt++) {
|
|
322
|
+
if (attempt > 0)
|
|
323
|
+
await pause(RESUME_BACKOFF_MS[Math.min(attempt - 1, RESUME_BACKOFF_MS.length - 1)]);
|
|
324
|
+
if (listening.closed)
|
|
325
|
+
return;
|
|
326
|
+
try {
|
|
327
|
+
// The link reconnects on its own; this waits for it rather than racing it.
|
|
328
|
+
await this.link.ready();
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
// Closed for good means nobody is coming back. Anything else is the link still
|
|
332
|
+
// being down, which is exactly what the window is for.
|
|
333
|
+
if (error instanceof protocol_1.LinkError && error.code === "closed")
|
|
334
|
+
break;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// An attachment from before may still be registered against a connection that is
|
|
338
|
+
// itself still alive, and the server allows only one per conversation. Dropping it
|
|
339
|
+
// first costs nothing when there is none to drop.
|
|
340
|
+
try {
|
|
341
|
+
this.link.send("conversation.detach", { chatId });
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Not connected. The server dropped the attachment with the socket.
|
|
345
|
+
}
|
|
346
|
+
let payload;
|
|
347
|
+
try {
|
|
348
|
+
payload = await this.watch(chatId, progress, deliver);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (error instanceof protocol_1.LinkError && error.code === "closed")
|
|
352
|
+
break;
|
|
353
|
+
if (error instanceof protocol_1.LinkError && error.code === "no_active_turn" && options.quietWhenGone)
|
|
354
|
+
return;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (payload.ok) {
|
|
358
|
+
if (!progress.sawCompletion)
|
|
359
|
+
deliver((0, transport_1.completedPayload)(payload.chatId ?? chatId));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// Not picked up yet, or suspended again mid-hop. Both mean it is still moving.
|
|
363
|
+
if (payload.code === "no_active_turn") {
|
|
364
|
+
if (options.quietWhenGone)
|
|
365
|
+
return;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (payload.code === "turn_suspended") {
|
|
369
|
+
if (payload.lastEventId)
|
|
370
|
+
progress.lastEventId = payload.lastEventId;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be picked back up.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? chatId));
|
|
222
374
|
return;
|
|
223
375
|
}
|
|
224
|
-
|
|
225
|
-
// invisible to the caller, and the message has not been delivered yet.
|
|
226
|
-
if (payload.code === "unknown_session" && mayRetry) {
|
|
227
|
-
this.sessionId = undefined;
|
|
228
|
-
await this.runTurn(request, handlers, false);
|
|
376
|
+
if (listening.closed)
|
|
229
377
|
return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
378
|
+
// The completion already reached the caller; there is nothing left to say.
|
|
379
|
+
if (progress.sawCompletion)
|
|
380
|
+
return;
|
|
381
|
+
if (options.quietWhenGone)
|
|
382
|
+
return;
|
|
383
|
+
deliver((0, transport_1.failurePayload)("turn_suspended", "The turn could not be picked back up.", "I'm afraid I lost track of that answer. It may well have finished — reopen the conversation to see where it got to.", chatId));
|
|
235
384
|
}
|
|
236
385
|
/** Opens a session, or reuses the open one when it is for the same conversation. */
|
|
237
386
|
async session(request) {
|
|
@@ -257,3 +406,10 @@ class LinkConversationTransport {
|
|
|
257
406
|
}
|
|
258
407
|
}
|
|
259
408
|
exports.LinkConversationTransport = LinkConversationTransport;
|
|
409
|
+
/** Waits, without keeping a Node process alive on its own. */
|
|
410
|
+
function pause(ms) {
|
|
411
|
+
return new Promise(resolve => {
|
|
412
|
+
const timer = setTimeout(resolve, ms);
|
|
413
|
+
timer.unref?.();
|
|
414
|
+
});
|
|
415
|
+
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -78,7 +78,12 @@ between you and the socket.
|
|
|
78
78
|
|
|
79
79
|
## Link
|
|
80
80
|
|
|
81
|
-
A Link is a live connection to Alfred
|
|
81
|
+
A Link is a live connection to Alfred, served by its own endpoint — `link.butler.now`,
|
|
82
|
+
not the core API server. `createLink` goes there by default; pass `linkUrl` to the client
|
|
83
|
+
(or `serverUrl` to `createLink`) to point somewhere else. A client given a `serverUrl` of
|
|
84
|
+
its own — a self-hosted stack — uses that for links too.
|
|
85
|
+
|
|
86
|
+
It does three things:
|
|
82
87
|
|
|
83
88
|
- **Tools** — Alfred calls code that runs on your machine
|
|
84
89
|
- **Hooks** — your code wakes the user's background agents when something happens
|
|
@@ -212,6 +217,45 @@ whatever tier and permission gating it carries, which tools bolted onto the chat
|
|
|
212
217
|
An unknown platform is rejected when you register, not ignored: a tool reachable from nowhere looks
|
|
213
218
|
exactly like a tool that is broken.
|
|
214
219
|
|
|
220
|
+
### Agents: your tools behind a worker of your own
|
|
221
|
+
|
|
222
|
+
When your client has more tools than a chat should see, or tools that only make sense together,
|
|
223
|
+
declare an agent. It lives on the server for as long as the link does, works with tools that run
|
|
224
|
+
here, and to Alfred it is one tool:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { Agent, Tool } from "@butlerbot/sdk";
|
|
228
|
+
|
|
229
|
+
const grind = new Tool({ id: "grind", description: "Grind beans.", run: async () => grinder.run() });
|
|
230
|
+
const brew = new Tool({ id: "brew", description: "Brew from the ground beans.", run: async () => machine.brew() });
|
|
231
|
+
|
|
232
|
+
const barista = new Agent({
|
|
233
|
+
id: "barista",
|
|
234
|
+
name: "Barista",
|
|
235
|
+
description: "Runs the kitchen coffee machine: grinding, brewing, cleaning.",
|
|
236
|
+
prompt: "You operate a coffee machine. Always grind before you brew. Report what you made.",
|
|
237
|
+
model: "DeepSeek-V4-Flash",
|
|
238
|
+
tools: [grind, brew],
|
|
239
|
+
});
|
|
240
|
+
link.addAgent(barista);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The tools come with the agent — no `addTool` for them, and Alfred never sees them directly. The
|
|
244
|
+
prompt is where a thousand tools become one coherent worker.
|
|
245
|
+
|
|
246
|
+
You can talk to the agent yourself. The run happens on the server, on your account; the question
|
|
247
|
+
and the answer live here:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const { text } = await barista.chat("Make me a flat white.");
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
One `Agent` remembers across `chat` calls; pass `{ thread }` to name the conversation yourself, or
|
|
254
|
+
`newThread()` to start over. This is what makes a hook callback useful on its own: something
|
|
255
|
+
happens, your code notices, and you ask an agent what to do about it.
|
|
256
|
+
|
|
257
|
+
Needs `link.tools.register` to declare agents, and `tools.run` to talk to one directly.
|
|
258
|
+
|
|
215
259
|
### Tools belong to the user, not to a conversation
|
|
216
260
|
|
|
217
261
|
Once a tool is registered, Alfred can call it anywhere that user talks to it — the web
|
|
@@ -247,9 +291,15 @@ Which to use:
|
|
|
247
291
|
Best when you are already running a link for tools or hooks, or holding many
|
|
248
292
|
conversations at once — one socket carries them all.
|
|
249
293
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
294
|
+
A turn survives losing the connection it was asked for on. Sessions are ephemeral — the
|
|
295
|
+
server drops one with its socket — but the turn belongs to the conversation, so when the
|
|
296
|
+
socket goes mid-answer the SDK reconnects, rejoins the turn from the last event it gave
|
|
297
|
+
you, and carries on into the same stream. The same holds when the platform deploys
|
|
298
|
+
mid-turn and the answer is handed to another instance. A message that had not been
|
|
299
|
+
delivered yet is simply sent again on a fresh session; one that had is never sent twice.
|
|
300
|
+
|
|
301
|
+
If the turn cannot be picked back up within a minute, the stream ends with a failure that
|
|
302
|
+
says so — the reply may still have finished, and reopening the conversation will show it.
|
|
253
303
|
|
|
254
304
|
### Rejoining a turn already in progress
|
|
255
305
|
|