viber-channel 0.6.0 → 0.7.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * agent_tools.ts — shared logic for the three channel tools (#317).
3
+ *
4
+ * The Claude MCP server (viber-channel.ts) and the Codex bridge
5
+ * (viber-codex-bridge.ts) must expose the SAME tools with the SAME behaviour —
6
+ * one source, no double maintenance. This module holds that behaviour as pure,
7
+ * context-injected functions; each host wraps them in a thin adapter (an MCP
8
+ * CallTool handler for Claude, an in-process MCP server for Codex).
9
+ *
10
+ * The context is read through LIVE getters, never a snapshot: the host's
11
+ * conversation token rotates (token refresh / re-mint) and these functions must
12
+ * always read the current value at call time. That is why the context exposes
13
+ * functions, not fields.
14
+ *
15
+ * messages.ts (postMessage/parseArtifact) and peers.ts (listPeers/openDm) stay
16
+ * pure HTTP clients — this module orchestrates them and maps results to the
17
+ * MCP tool-result shape. It does NOT hold state.
18
+ */
19
+ import { postMessage, parseArtifact, type Artifact } from "./messages.js";
20
+ import { listPeers, openDm, type OpenDmResult } from "./peers.js";
21
+ import { ConversationTokenExpiredError } from "./messages.js";
22
+
23
+ /** MCP tool-result shape (text content + optional error flag). */
24
+ export interface AgentToolResult {
25
+ isError?: boolean;
26
+ content: { type: "text"; text: string }[];
27
+ }
28
+
29
+ /**
30
+ * Live, by-reference access to the host channel's runtime state. Implemented by
31
+ * both hosts so the shared tool logic never reads a stale snapshot. Every getter
32
+ * is called at tool-invocation time.
33
+ */
34
+ export interface AgentToolsContext {
35
+ /** API base URL (e.g. https://viber-dev.dgypx.dev). */
36
+ baseUrl(): string;
37
+ /** This instance's durable instance_token (Bearer) for the agent endpoints. */
38
+ instanceToken(): string;
39
+ /** WebSocket base URL used to stream an opened DM (empty until ready). */
40
+ voiceBaseUrl(): string;
41
+ /** Conversation id send_message posts into (main conv, or the active DM). */
42
+ sendConversationId(): string;
43
+ /** Conversation token paired with sendConversationId(). */
44
+ sendConversationToken(): string;
45
+ /**
46
+ * Start streaming a freshly opened DM so the peer's reply surfaces back on the
47
+ * host channel. The host supplies its own ws base URL; the lib only forwards
48
+ * the DM coordinates. Fire-and-forget (parity with the Claude channel).
49
+ */
50
+ startDmStream(dm: OpenDmResult): void;
51
+ /**
52
+ * Optional: the current stream's AbortSignal, passed to postMessage so a post
53
+ * in flight is cancelled atomically if the stream is torn down (re-join /
54
+ * revoke / shutdown). The Claude channel returns undefined (it never passed a
55
+ * signal); the Codex bridge returns its per-turn stream signal (#303 parity).
56
+ */
57
+ abortSignal?(): AbortSignal | undefined;
58
+ }
59
+
60
+ function text(s: string): AgentToolResult {
61
+ return { content: [{ type: "text" as const, text: s }] };
62
+ }
63
+
64
+ function errorText(s: string): AgentToolResult {
65
+ return { isError: true, content: [{ type: "text" as const, text: s }] };
66
+ }
67
+
68
+ /**
69
+ * list_agents — return this project's other agents so the model can pick one to
70
+ * message. Guards the startup window: an empty instance token means "channel not
71
+ * ready" rather than hitting the peers endpoint with no credential.
72
+ */
73
+ export async function listAgents(ctx: AgentToolsContext): Promise<AgentToolResult> {
74
+ if (!ctx.instanceToken()) {
75
+ return errorText("Channel not ready: no instance identity yet.");
76
+ }
77
+ try {
78
+ const peers = await listPeers(ctx.baseUrl(), ctx.instanceToken());
79
+ // Project only what the model needs to pick a peer (drop last_seen /
80
+ // active_conversation_id — available over the wire if a future tool needs them).
81
+ const summary = peers.map((p) => ({ id: p.id, label: p.label, kind: p.kind, online: p.online }));
82
+ const body =
83
+ summary.length === 0
84
+ ? "No other agents are currently registered in this project."
85
+ : JSON.stringify(summary, null, 2);
86
+ return text(body);
87
+ } catch (err) {
88
+ return errorText(`list_agents failed: ${String(err)}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * message_agent — open/reuse a DM with a peer and post a message, then start
94
+ * streaming that DM so the peer's reply arrives back on this channel. Mirrors
95
+ * the Claude MCP behaviour exactly: structured outcomes for offline / transient
96
+ * conflict, a clean retry hint on an expired DM token, and the DM stream started
97
+ * BEFORE the post so we are subscribed before the peer can reply.
98
+ */
99
+ export async function messageAgent(
100
+ ctx: AgentToolsContext,
101
+ args: Record<string, unknown>,
102
+ ): Promise<AgentToolResult> {
103
+ if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
104
+ return errorText("Channel not ready: no instance identity yet.");
105
+ }
106
+ const peerId = args.instance_id;
107
+ const body = args.text;
108
+ if (typeof peerId !== "string" || peerId.trim().length === 0) {
109
+ return errorText("Invalid input: instance_id must be a non-empty string");
110
+ }
111
+ if (typeof body !== "string" || body.trim().length === 0) {
112
+ return errorText("Invalid input: text must be a non-empty string");
113
+ }
114
+ const trimmedPeerId = peerId.trim();
115
+
116
+ let outcome;
117
+ try {
118
+ outcome = await openDm(ctx.baseUrl(), ctx.instanceToken(), trimmedPeerId);
119
+ } catch (err) {
120
+ return errorText(`message_agent failed: ${String(err)}`);
121
+ }
122
+ if (!outcome.ok) {
123
+ let friendly: string;
124
+ if (outcome.detail === "target_offline") {
125
+ friendly = "That agent is offline right now — it must be running to receive a DM.";
126
+ } else if (outcome.detail === "dm_unavailable_retry") {
127
+ friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
128
+ } else {
129
+ friendly = `Could not open a DM: ${outcome.detail}`;
130
+ }
131
+ return errorText(friendly);
132
+ }
133
+
134
+ // Subscribe before posting so the peer's reply (which requires the peer agent
135
+ // to process the message, i.e. seconds) always loses the race to our subscribe.
136
+ ctx.startDmStream(outcome.dm);
137
+
138
+ let result;
139
+ try {
140
+ result = await postMessage(
141
+ ctx.baseUrl(),
142
+ outcome.dm.conversation_id,
143
+ outcome.dm.conversation_token,
144
+ body,
145
+ undefined,
146
+ ctx.abortSignal?.(),
147
+ );
148
+ } catch (err) {
149
+ // A DM membership token expired — unlike send_message we do NOT tear down the
150
+ // host here (the main conversation is unaffected). Tell the model to retry;
151
+ // a retry re-opens the DM with a fresh token.
152
+ if (err instanceof ConversationTokenExpiredError) {
153
+ return errorText("The DM session token expired — call message_agent again to re-open and resend.");
154
+ }
155
+ return errorText(`message_agent post failed: ${String(err)}`);
156
+ }
157
+ if (!result.ok) {
158
+ return errorText(result.detail);
159
+ }
160
+ return text(`Message sent to agent (DM ${outcome.dm.conversation_id}).`);
161
+ }
162
+
163
+ /**
164
+ * send_message — post text (+ optional artifact) into the conversation this turn
165
+ * belongs to. Guards the startup window (empty conversation token/id) and
166
+ * validates input identically to the Claude MCP.
167
+ *
168
+ * NB: a ConversationTokenExpiredError from postMessage is NOT caught here — the
169
+ * host adapter handles it (Claude tears down the channel; the bridge surfaces a
170
+ * controlled shutdown), because that policy differs per host.
171
+ */
172
+ export async function sendMessage(
173
+ ctx: AgentToolsContext,
174
+ args: Record<string, unknown>,
175
+ ): Promise<AgentToolResult> {
176
+ if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
177
+ return errorText("Channel not ready: no conversation yet.");
178
+ }
179
+ const body = args.text;
180
+ if (typeof body !== "string" || body.trim().length === 0) {
181
+ return errorText("Invalid input: text must be a non-empty string");
182
+ }
183
+ const parsed = parseArtifact(args.artifact);
184
+ if (!parsed.ok) {
185
+ return errorText(`Invalid input: ${parsed.error}`);
186
+ }
187
+ const artifact: Artifact | undefined = parsed.artifact;
188
+
189
+ const result = await postMessage(
190
+ ctx.baseUrl(),
191
+ ctx.sendConversationId(),
192
+ ctx.sendConversationToken(),
193
+ body,
194
+ artifact,
195
+ ctx.abortSignal?.(),
196
+ );
197
+ if (!result.ok) {
198
+ return errorText(result.detail);
199
+ }
200
+ return text(`Message sent: ${result.message_id}`);
201
+ }