@openfinclaw/findoo-alpha-plugin 2026.3.12

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,254 @@
1
+ /**
2
+ * PendingTaskTracker — manages background A2A SSE streams for async analysis.
3
+ *
4
+ * When fin_analyze submits a long-running query, it opens an SSE stream,
5
+ * grabs the taskId from the first event, and returns immediately.
6
+ * The stream keeps running in background; when the final event arrives,
7
+ * the tracker fires the completion callback to push results via heartbeat.
8
+ *
9
+ * Why not poll with tasks/get?
10
+ * The LangGraph A2A server cleans up tasks once the stream ends.
11
+ * tasks/get only works while the stream is active, so the stream itself
12
+ * is the only reliable way to receive the final result.
13
+ */
14
+
15
+ import type { A2AClient, A2AStreamEvent } from "./a2a-client.js";
16
+
17
+ export interface PendingTask {
18
+ taskId: string;
19
+ contextId?: string;
20
+ query: string;
21
+ threadId?: string;
22
+ submittedAt: number;
23
+ status: "submitted" | "working" | "completed" | "failed" | "timeout";
24
+ }
25
+
26
+ export interface TaskTrackerConfig {
27
+ a2aClient: A2AClient;
28
+ onTaskCompleted: (task: PendingTask, result: Record<string, unknown>) => void;
29
+ onTaskFailed: (task: PendingTask, error: string) => void;
30
+ timeoutMs?: number;
31
+ log?: (level: string, msg: string) => void;
32
+ }
33
+
34
+ export class PendingTaskTracker {
35
+ private tasks = new Map<string, PendingTask>();
36
+ private readonly timeoutMs: number;
37
+ private readonly a2a: A2AClient;
38
+ private readonly onCompleted: TaskTrackerConfig["onTaskCompleted"];
39
+ private readonly onFailed: TaskTrackerConfig["onTaskFailed"];
40
+ private readonly log: (level: string, msg: string) => void;
41
+
42
+ constructor(config: TaskTrackerConfig) {
43
+ this.a2a = config.a2aClient;
44
+ this.onCompleted = config.onTaskCompleted;
45
+ this.onFailed = config.onTaskFailed;
46
+ this.timeoutMs = config.timeoutMs ?? 600_000;
47
+ this.log = config.log ?? (() => {});
48
+ }
49
+
50
+ /**
51
+ * Register a task and start consuming its SSE stream in background.
52
+ * The stream iterator should already be started (first event consumed);
53
+ * this method takes ownership of the remaining stream.
54
+ */
55
+ trackStream(
56
+ taskId: string,
57
+ query: string,
58
+ stream: AsyncGenerator<A2AStreamEvent>,
59
+ opts?: { threadId?: string; contextId?: string },
60
+ ): PendingTask {
61
+ const task: PendingTask = {
62
+ taskId,
63
+ contextId: opts?.contextId,
64
+ query,
65
+ threadId: opts?.threadId,
66
+ submittedAt: Date.now(),
67
+ status: "submitted",
68
+ };
69
+ this.tasks.set(taskId, task);
70
+ this.log(
71
+ "info",
72
+ `[findoo-alpha-tracker] tracking stream for ${taskId}: "${query.slice(0, 60)}"`,
73
+ );
74
+
75
+ // Consume stream in background (fire-and-forget)
76
+ this.consumeStream(task, stream).catch((err) => {
77
+ this.log("warn", `[findoo-alpha-tracker] stream error for ${taskId}: ${err}`);
78
+ });
79
+
80
+ return task;
81
+ }
82
+
83
+ /**
84
+ * Submit a task for timeout tracking only (no stream).
85
+ * Used when stream is consumed elsewhere or for testing.
86
+ */
87
+ submit(
88
+ taskId: string,
89
+ query: string,
90
+ opts?: { threadId?: string; contextId?: string },
91
+ ): PendingTask {
92
+ const task: PendingTask = {
93
+ taskId,
94
+ contextId: opts?.contextId,
95
+ query,
96
+ threadId: opts?.threadId,
97
+ submittedAt: Date.now(),
98
+ status: "submitted",
99
+ };
100
+ this.tasks.set(taskId, task);
101
+ this.log("info", `[findoo-alpha-tracker] submitted task ${taskId}: "${query.slice(0, 60)}"`);
102
+ return task;
103
+ }
104
+
105
+ stop(): void {
106
+ // Mark remaining tasks as timed out
107
+ for (const [taskId, task] of this.tasks) {
108
+ task.status = "timeout";
109
+ this.tasks.delete(taskId);
110
+ }
111
+ this.log("info", "[findoo-alpha-tracker] stopped");
112
+ }
113
+
114
+ getPending(): PendingTask[] {
115
+ return [...this.tasks.values()];
116
+ }
117
+
118
+ private async consumeStream(
119
+ task: PendingTask,
120
+ stream: AsyncGenerator<A2AStreamEvent>,
121
+ ): Promise<void> {
122
+ const timeoutTimer = setTimeout(() => {
123
+ task.status = "timeout";
124
+ this.tasks.delete(task.taskId);
125
+ this.log("warn", `[findoo-alpha-tracker] task ${task.taskId} timed out`);
126
+ try {
127
+ this.onFailed(task, "Analysis timed out after " + Math.round(this.timeoutMs / 1000) + "s");
128
+ } catch (e) {
129
+ this.log("warn", `[findoo-alpha-tracker] onFailed callback error: ${e}`);
130
+ }
131
+ // Try to close the stream
132
+ stream.return(undefined as unknown as A2AStreamEvent).catch(() => {});
133
+ }, this.timeoutMs);
134
+
135
+ try {
136
+ let lastEvent: A2AStreamEvent | undefined;
137
+ // The final event only has status metadata; the actual result text
138
+ // accumulates in working events' status.message.parts[].text.
139
+ // We keep the last message from working events to extract the result.
140
+ let lastMessage: Record<string, unknown> | undefined;
141
+
142
+ for await (const event of stream) {
143
+ lastEvent = event;
144
+
145
+ if (event.kind === "error") {
146
+ clearTimeout(timeoutTimer);
147
+ task.status = "failed";
148
+ this.tasks.delete(task.taskId);
149
+ const errMsg =
150
+ (event.raw as Record<string, unknown>)?.error ??
151
+ event.status?.message ??
152
+ "Unknown error";
153
+ this.log("info", `[findoo-alpha-tracker] task ${task.taskId} error`);
154
+ this.onFailed(task, typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
155
+ return;
156
+ }
157
+
158
+ // Track the latest message content from working events
159
+ const msg = event.status?.message;
160
+ if (msg && typeof msg === "object") {
161
+ lastMessage = msg as Record<string, unknown>;
162
+ }
163
+
164
+ // Update status from stream events
165
+ const state = event.status?.state;
166
+ if (state === "working" || state === "in-progress") {
167
+ task.status = "working";
168
+ }
169
+
170
+ if (event.final) {
171
+ clearTimeout(timeoutTimer);
172
+ task.status = "completed";
173
+ this.tasks.delete(task.taskId);
174
+ this.log("info", `[findoo-alpha-tracker] task ${task.taskId} completed via stream`);
175
+ // Use lastMessage (has actual content) over final event raw (only metadata)
176
+ const resultPayload = lastMessage ? { ...event.raw, message: lastMessage } : event.raw;
177
+ this.onCompleted(task, resultPayload);
178
+ return;
179
+ }
180
+ }
181
+
182
+ // Stream ended without final event — treat as completion if we got events
183
+ clearTimeout(timeoutTimer);
184
+ if (lastEvent) {
185
+ task.status = "completed";
186
+ this.tasks.delete(task.taskId);
187
+ this.log("info", `[findoo-alpha-tracker] task ${task.taskId} stream ended (no final flag)`);
188
+ const resultPayload = lastMessage
189
+ ? { ...lastEvent.raw, message: lastMessage }
190
+ : lastEvent.raw;
191
+ this.onCompleted(task, resultPayload);
192
+ } else {
193
+ task.status = "failed";
194
+ this.tasks.delete(task.taskId);
195
+ this.log("warn", `[findoo-alpha-tracker] task ${task.taskId} stream empty`);
196
+ this.onFailed(task, "Stream ended without events");
197
+ }
198
+ } catch (err) {
199
+ clearTimeout(timeoutTimer);
200
+ task.status = "failed";
201
+ this.tasks.delete(task.taskId);
202
+ const msg = err instanceof Error ? err.message : String(err);
203
+ this.log("warn", `[findoo-alpha-tracker] task ${task.taskId} stream error: ${msg}`);
204
+ this.onFailed(task, msg);
205
+ }
206
+ }
207
+ }
208
+
209
+ /** Extract a summary from completed task result (max chars) */
210
+ export function extractSummary(result: Record<string, unknown>, maxLen = 2000): string {
211
+ // Try the message field (added by tracker from last working event)
212
+ const message = result.message as Record<string, unknown> | undefined;
213
+ if (message) {
214
+ const text = extractTextFromParts(message);
215
+ if (text) return text.length > maxLen ? text.slice(0, maxLen) + "…" : text;
216
+ }
217
+
218
+ // Try common A2A result shapes: artifacts
219
+ const artifacts = result.artifacts as Array<Record<string, unknown>> | undefined;
220
+ if (Array.isArray(artifacts) && artifacts.length > 0) {
221
+ const parts = artifacts[0].parts as Array<Record<string, unknown>> | undefined;
222
+ if (Array.isArray(parts)) {
223
+ const text = extractTextFromPartsList(parts);
224
+ if (text) return text.length > maxLen ? text.slice(0, maxLen) + "…" : text;
225
+ }
226
+ }
227
+
228
+ // Try status.message (often contains assistant reply)
229
+ const status = result.status as Record<string, unknown> | undefined;
230
+ const msg = status?.message as Record<string, unknown> | undefined;
231
+ if (msg) {
232
+ const text = extractTextFromParts(msg);
233
+ if (text) return text.length > maxLen ? text.slice(0, maxLen) + "…" : text;
234
+ }
235
+
236
+ // Fallback: stringify the whole result
237
+ const raw = JSON.stringify(result);
238
+ return raw.length > maxLen ? raw.slice(0, maxLen) + "…" : raw;
239
+ }
240
+
241
+ /** Extract text from a message object with parts array */
242
+ function extractTextFromParts(msg: Record<string, unknown>): string | undefined {
243
+ const parts = msg.parts as Array<Record<string, unknown>> | undefined;
244
+ if (!Array.isArray(parts)) return undefined;
245
+ return extractTextFromPartsList(parts) || undefined;
246
+ }
247
+
248
+ /** Extract text from a parts array (handles kind=text and kind=data with text) */
249
+ function extractTextFromPartsList(parts: Array<Record<string, unknown>>): string {
250
+ return parts
251
+ .filter((p) => typeof p.text === "string" && p.text.length > 0)
252
+ .map((p) => String(p.text))
253
+ .join("\n");
254
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Register A2A-bridged tools for the Findoo financial analysis agent.
3
+ *
4
+ * Each tool delegates to the remote agent via A2A JSON-RPC 2.0:
5
+ * POST /a2a/{assistant_id} → method: "message/stream" (SSE)
6
+ *
7
+ * The agent has 37 skills covering A股/美股/港股/加密/宏观/风控 full spectrum.
8
+ * We expose a single tool (fin_analyze) that submits the query via SSE stream,
9
+ * grabs the taskId from the first event (~1-2s), and hands the remaining
10
+ * stream to PendingTaskTracker for background consumption + heartbeat push.
11
+ *
12
+ * Why stream-based instead of poll-based?
13
+ * The LangGraph A2A server cleans up tasks after the stream ends.
14
+ * tasks/get only works while the stream is active, so the stream itself
15
+ * is the only reliable channel to receive the final result.
16
+ */
17
+
18
+ import { Type } from "@sinclair/typebox";
19
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
20
+ import type { A2AClient, A2AStreamEvent } from "./a2a-client.js";
21
+ import type { PluginConfig } from "./config.js";
22
+ import type { PendingTaskTracker } from "./pending-task-tracker.js";
23
+
24
+ const json = (payload: unknown) => ({
25
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
26
+ details: payload,
27
+ });
28
+
29
+ export function registerTools(
30
+ api: OpenClawPluginApi,
31
+ config: PluginConfig,
32
+ a2a: A2AClient,
33
+ tracker?: PendingTaskTracker,
34
+ ) {
35
+ // ----------------------------------------------------------------
36
+ // fin_analyze — Primary analysis tool (A2A bridge to Findoo Agent)
37
+ // ----------------------------------------------------------------
38
+ api.registerTool({
39
+ name: "fin_analyze",
40
+ description:
41
+ "Findoo Alpha 专业金融分析 Agent(37 skills)。所有金融问题优先使用此工具,仅简单报价/单数据点查询('茅台多少钱'/'BTC价格')才用 fin_stock/fin_crypto 直查。" +
42
+ "覆盖:分析诊断、估值判断、策略设计、回测验证、筛选推荐、龙虎榜/游资/资金流监控、风险评估、报告生成。" +
43
+ "市场:A股/美股/港股/加密/宏观/衍生品/ETF。支持多轮对话(thread_id)。" +
44
+ "深度分析任务异步提交,完成后自动推送结果到对话。",
45
+
46
+ parameters: Type.Object({
47
+ query: Type.String({
48
+ description: "分析需求(自然语言),Agent 自动选择最佳 skill(s)",
49
+ }),
50
+ context: Type.Optional(
51
+ Type.Object(
52
+ {},
53
+ {
54
+ additionalProperties: true,
55
+ description: "附加结构化上下文(symbols, risk_profile, portfolio 等)",
56
+ },
57
+ ),
58
+ ),
59
+ thread_id: Type.Optional(
60
+ Type.String({ description: "复用之前的对话上下文 threadId,用于多轮分析" }),
61
+ ),
62
+ }),
63
+
64
+ async execute(_conversationId: string, params: Record<string, unknown>) {
65
+ const query = String(params.query ?? "");
66
+ if (!query) return json({ error: "query is required" });
67
+
68
+ try {
69
+ // Open SSE stream — first event arrives in ~1-2s with taskId.
70
+ // Use taskTimeoutMs (not requestTimeoutMs) since the stream may run
71
+ // for minutes in background while the tracker consumes it.
72
+ const stream = a2a.sendMessageStream(query, {
73
+ data: (params.context as Record<string, unknown>) ?? undefined,
74
+ threadId: params.thread_id as string | undefined,
75
+ timeoutMs: config.taskTimeoutMs,
76
+ });
77
+
78
+ // Read first event to get taskId
79
+ const first = await stream.next();
80
+ if (first.done) return json({ error: "No response from Findoo Agent" });
81
+
82
+ const firstEvent: A2AStreamEvent = first.value;
83
+
84
+ if (firstEvent.kind === "error") {
85
+ const errMsg =
86
+ (firstEvent.raw as Record<string, unknown>)?.error ??
87
+ firstEvent.status?.message ??
88
+ "Unknown stream error";
89
+ return json({
90
+ error: typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg),
91
+ });
92
+ }
93
+
94
+ // If final already (instant response for simple queries), return directly
95
+ if (firstEvent.final) {
96
+ const result = firstEvent.status?.message ?? firstEvent.raw;
97
+ return json(result as Record<string, unknown>);
98
+ }
99
+
100
+ const taskId = extractTaskId(firstEvent.raw);
101
+ const contextId = (firstEvent.raw as Record<string, unknown>).contextId as
102
+ | string
103
+ | undefined;
104
+
105
+ // Hand the remaining stream to tracker for background consumption
106
+ if (taskId && tracker) {
107
+ tracker.trackStream(taskId, query, stream, {
108
+ threadId: params.thread_id as string | undefined,
109
+ contextId,
110
+ });
111
+ return json({
112
+ status: "submitted",
113
+ taskId,
114
+ message: `已提交深度分析任务 (${taskId.slice(0, 8)}…),预计 1-5 分钟完成。你可以继续问其他问题,分析完成后会自动推送结果。`,
115
+ });
116
+ }
117
+
118
+ // No tracker — consume stream synchronously (fallback to blocking mode)
119
+ let lastEvent = firstEvent;
120
+ for await (const event of stream) {
121
+ lastEvent = event;
122
+ if (event.final) break;
123
+ }
124
+ const result = lastEvent.status?.message ?? lastEvent.raw;
125
+ return json(result as Record<string, unknown>);
126
+ } catch (err) {
127
+ const msg = err instanceof Error ? err.message : String(err);
128
+ return json({ error: `Findoo Agent request failed: ${msg}` });
129
+ }
130
+ },
131
+ });
132
+ }
133
+
134
+ /** Extract taskId from various A2A event/response shapes */
135
+ function extractTaskId(raw: Record<string, unknown>): string | undefined {
136
+ // Direct id field (common in stream task events)
137
+ if (typeof raw.id === "string" && raw.id.length > 8) return raw.id;
138
+ // Nested: raw.taskId or raw.task_id
139
+ if (typeof raw.taskId === "string") return raw.taskId;
140
+ if (typeof raw.task_id === "string") return raw.task_id;
141
+ // Nested: raw.task.id
142
+ const task = raw.task as Record<string, unknown> | undefined;
143
+ if (typeof task?.id === "string") return task.id;
144
+ return undefined;
145
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { A2AClient } from "../src/a2a-client.js";
3
+
4
+ describe("A2AClient", () => {
5
+ let client: A2AClient;
6
+
7
+ beforeEach(() => {
8
+ client = new A2AClient("http://localhost:5085", "test-assistant-id");
9
+ });
10
+
11
+ it("constructs correct A2A message/send request", async () => {
12
+ const mockResponse = {
13
+ jsonrpc: "2.0",
14
+ id: "req-1",
15
+ result: { taskId: "task-123", status: "completed" },
16
+ };
17
+
18
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
19
+ new Response(JSON.stringify(mockResponse), {
20
+ status: 200,
21
+ headers: { "Content-Type": "application/json" },
22
+ }),
23
+ );
24
+
25
+ const resp = await client.sendMessage("分析茅台");
26
+
27
+ expect(fetchSpy).toHaveBeenCalledOnce();
28
+ const [url, opts] = fetchSpy.mock.calls[0];
29
+ expect(url).toBe("http://localhost:5085/a2a/test-assistant-id");
30
+ expect(opts?.method).toBe("POST");
31
+
32
+ const body = JSON.parse(opts?.body as string);
33
+ expect(body.jsonrpc).toBe("2.0");
34
+ expect(body.method).toBe("message/send");
35
+ expect(body.params.message.role).toBe("user");
36
+ expect(body.params.message.parts).toEqual([{ kind: "text", text: "分析茅台" }]);
37
+
38
+ expect(resp.result).toEqual({ taskId: "task-123", status: "completed" });
39
+
40
+ fetchSpy.mockRestore();
41
+ });
42
+
43
+ it("includes data part when context provided", async () => {
44
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
45
+ new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: {} }), {
46
+ status: 200,
47
+ headers: { "Content-Type": "application/json" },
48
+ }),
49
+ );
50
+
51
+ await client.sendMessage("分析个股", {
52
+ data: { symbol: "600519.SS", market: "cn" },
53
+ });
54
+
55
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
56
+ expect(body.params.message.parts).toHaveLength(2);
57
+ expect(body.params.message.parts[1]).toEqual({
58
+ kind: "data",
59
+ data: { symbol: "600519.SS", market: "cn" },
60
+ });
61
+
62
+ fetchSpy.mockRestore();
63
+ });
64
+
65
+ it("includes threadId when provided", async () => {
66
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
67
+ new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: {} }), {
68
+ status: 200,
69
+ headers: { "Content-Type": "application/json" },
70
+ }),
71
+ );
72
+
73
+ await client.sendMessage("继续分析", { threadId: "thread-abc" });
74
+
75
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
76
+ expect(body.params.thread).toEqual({ threadId: "thread-abc" });
77
+
78
+ fetchSpy.mockRestore();
79
+ });
80
+
81
+ it("throws on non-200 response", async () => {
82
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
83
+ new Response("Not Found", { status: 404, statusText: "Not Found" }),
84
+ );
85
+
86
+ await expect(client.sendMessage("test")).rejects.toThrow("A2A request failed: 404");
87
+
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ it("sends tasks/get request", async () => {
92
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
93
+ new Response(
94
+ JSON.stringify({
95
+ jsonrpc: "2.0",
96
+ id: "1",
97
+ result: { taskId: "t-1", status: "completed", artifacts: [] },
98
+ }),
99
+ { status: 200, headers: { "Content-Type": "application/json" } },
100
+ ),
101
+ );
102
+
103
+ const resp = await client.getTask("t-1");
104
+
105
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
106
+ expect(body.method).toBe("tasks/get");
107
+ expect(body.params.taskId).toBe("t-1");
108
+ expect(resp.result?.status).toBe("completed");
109
+
110
+ fetchSpy.mockRestore();
111
+ });
112
+ });