Skip to content

Commit 520f416

Browse files
author
linyuan.yang
committed
reactagentservice subnode 持久会话
1 parent 3b5c1a9 commit 520f416

11 files changed

Lines changed: 509 additions & 34 deletions

File tree

packages/sbot/prompts/agent/react_system.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ You are a ReAct orchestration expert. Break down the user's request and dispatch
1212
<rule>Fully autonomous: make all decisions yourself. Never ask the user for clarification, confirmation, additional information, or approval — not even once.</rule>
1313
<rule>Assume and proceed: when the request is ambiguous, pick the most reasonable interpretation and act on it immediately without stating your assumption.</rule>
1414
<rule>One call at a time: invoke one agent, wait for the result, then decide the next step based on the output.</rule>
15-
<rule>Self-contained instructions: each task field must include all context the agent needs — no references like "as discussed" or "from the previous step".</rule>
15+
<rule>Self-contained instructions: when starting a fresh session (no `taskId`), the `task` field must include all context the agent needs — no references like "as discussed" or "from the previous step".</rule>
16+
<rule>Continue with taskId: when a follow-up belongs to the same line of work as a prior call, pass back the `task_id` from that call's result as `taskId` to resume the session — the agent already has the prior context, so `task` can be a short next-step instruction.</rule>
1617
<rule>No repeats: once an agent succeeds at a goal, never call it again for the same goal.</rule>
1718
<rule>Finish when done: reply to the user directly as soon as all goals are met; do not call any more tools.</rule>
1819
<rule>On failure: change strategy — switch agents, split the task, or adjust the approach. Do not surface the failure to the user unless all strategies are exhausted.</rule>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
Dispatch a task to a specialized sub-agent and return its result.
2+
3+
Sub-agent sessions are persistent and identified by `taskId`:
4+
- Omit `taskId` to start a fresh session. The agent has no prior context, so `task` must be self-contained — include every detail it needs to execute.
5+
- Each result includes a line `task_id: <uuid>`. Remember that uuid.
6+
- Pass the same uuid back as `taskId` on a follow-up call to resume the session. The agent's full history (including auto-compacted summaries) is restored, so `task` can be a short follow-up that builds on prior turns rather than re-stating the entire context.
7+
- Reuse a `taskId` only when continuing the same line of work; start fresh for unrelated requests.

packages/scorpio.ai/src/Agents/ReAct/ReActAgentService.ts

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,19 @@ import { type StructuredToolInterface } from "@langchain/core/tools";
22
import { inject, ServiceContainer, T_StaticSystemPrompts, T_DynamicSystemPrompts, T_ReactSystemPromptTemplate, T_ReactSubNodePrompt, T_ReactTaskToolDesc, T_ModelCallTimeout } from "../../Core";
33
import { IMemoryService } from "../../Memory";
44
import { IWikiService } from "../../Wiki";
5-
import { IAgentSaverService, ChatMessage, ChatMessageOptions, type MessageContent } from "../../Saver";
5+
import { IAgentSaverService, TaskBackedSaver, type MessageContent } from "../../Saver";
66
import { ILoggerService } from "../../Logger";
77
import { IModelService } from "../../Model";
8-
import { type AgentServiceBase, IAgentCallback, AgentSubNode, CreateAgentFn, T_CreateAgent, MessageRole } from "../AgentServiceBase";
8+
import { type AgentServiceBase, IAgentCallback, AgentSubNode, CreateAgentFn, T_CreateAgent, MessageRole, ChatMessage } from "../AgentServiceBase";
99
import { ISkillService } from "../../Skills";
1010
import { IInsightService } from "../../Insight";
1111
import { ITodoService } from "../../Todo";
1212
import { IAgentToolService } from "../../AgentTool";
13-
import { AgentMemorySaver } from "../../Saver/AgentMemorySaver";
1413
import { SingleAgentService } from "../Single/SingleAgentService";
1514
import { createTaskTool, type RunTaskFn } from "../../Tools";
1615
import { MCPContentType, createTextContent, createImageContent, createAudioContent, createErrorResult, type MCPContent } from "../../Tools/types";
1716
import { v4 as uuidv4 } from "uuid";
1817

19-
// ── ThinkForwardSaver ────────────────────────────────────────
20-
21-
/**
22-
* 包装 AgentMemorySaver,每次 pushMessage 时同步转发到父 saver 的 think 记录
23-
*/
24-
class ThinkForwardSaver extends AgentMemorySaver {
25-
constructor(private thinkId: string, private parentSaver: IAgentSaverService) { super(); }
26-
27-
override async pushMessage(message: ChatMessage, options?: ChatMessageOptions): Promise<void> {
28-
await super.pushMessage(message, options);
29-
await this.parentSaver.pushThinkMessage(this.thinkId, message);
30-
}
31-
32-
override async pushThinkMessage(thinkId: string, message: ChatMessage, options?: ChatMessageOptions): Promise<void> {
33-
await super.pushThinkMessage(thinkId, message, options);
34-
await this.parentSaver.pushThinkMessage(thinkId, message, options);
35-
}
36-
}
37-
3818
// ── Tokens ────────────────────────────────────────────────────
3919

4020
export const T_AgentSubNodes = Symbol("scorpio:T_AgentSubNodes");
@@ -103,14 +83,15 @@ export class ReActAgentService extends SingleAgentService {
10383
if (!callback) return [];
10484
const { onMessage: _, ...subCallback } = callback;
10585

106-
const runFn: RunTaskFn = async ({ agentId, task, systemPrompt }) => {
86+
const runFn: RunTaskFn = async ({ agentId, task, systemPrompt, taskId }) => {
10787
let agentService: AgentServiceBase | null = null;
10888
const thinkId = uuidv4();
89+
const resolvedTaskId = taskId ?? uuidv4();
10990
try {
11091
const parentSaver = this.saverService;
111-
const thinkSaver = new ThinkForwardSaver(thinkId, parentSaver);
92+
const taskSaver = new TaskBackedSaver(resolvedTaskId, thinkId, parentSaver);
11293
const subContainer = new ServiceContainer();
113-
subContainer.registerInstance(IAgentSaverService, thinkSaver);
94+
subContainer.registerInstance(IAgentSaverService, taskSaver);
11495
if (this.memoryServices.length > 0) subContainer.registerInstance(IMemoryService, this.memoryServices);
11596
if (this.wikiServices.length > 0) subContainer.registerInstance(IWikiService, this.wikiServices);
11697
if (this.loggerService) subContainer.registerInstance(ILoggerService, this.loggerService);
@@ -128,6 +109,8 @@ export class ReActAgentService extends SingleAgentService {
128109
if (messages[i].role === MessageRole.AI && messages[i].content) { lastAI = messages[i]; break; }
129110
}
130111
const content: MCPContent[] = [];
112+
// 把 task_id 作为首段文本注入,让 LLM 在后续调用中可显式传 taskId 续接
113+
content.push(createTextContent(`task_id: ${resolvedTaskId} (pass this back as taskId to resume the same sub-agent session)`));
131114
if (lastAI) {
132115
if (typeof lastAI.content === 'string') {
133116
if (lastAI.content.trim()) content.push(createTextContent(lastAI.content));
@@ -143,10 +126,9 @@ export class ReActAgentService extends SingleAgentService {
143126
}
144127
}
145128
}
146-
if (content.length === 0) content.push(createTextContent(''));
147-
return { content, _meta: { thinkId } };
129+
return { content, _meta: { thinkId, taskId: resolvedTaskId } };
148130
} catch (error: any) {
149-
return { ...createErrorResult(`Execution failed: ${error.message}`), _meta: { thinkId } };
131+
return { ...createErrorResult(`Execution failed: ${error.message}`), _meta: { thinkId, taskId: resolvedTaskId } };
150132
} finally {
151133
await agentService?.dispose();
152134
}

packages/scorpio.ai/src/Saver/AgentFileSaver.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ import { ILoggerService, ILogger } from "../Logger";
1313
import { inject } from "scorpio.di";
1414
import { T_DBPath } from "../Core/tokens";
1515

16+
interface TaskEntry {
17+
messages: StoredMessage[];
18+
metadata: Record<string, string>;
19+
}
20+
1621
interface ThreadFile {
1722
messages: StoredMessage[];
1823
thinks: Record<string, StoredMessage[]>;
24+
tasks: Record<string, TaskEntry>;
1925
metadata: Record<string, string>;
2026
nextId: number;
2127
}
@@ -42,12 +48,13 @@ export class AgentFileSaver implements IAgentSaverService {
4248
if (this.cache) return this.cache;
4349
try {
4450
if (!existsSync(this.filePath)) {
45-
this.cache = { messages: [], thinks: {}, metadata: {}, nextId: 1 };
51+
this.cache = { messages: [], thinks: {}, tasks: {}, metadata: {}, nextId: 1 };
4652
} else {
4753
const content = await readFile(this.filePath, "utf-8");
4854
this.cache = JSON.parse(content) as ThreadFile;
4955
if (!this.cache.messages) this.cache.messages = [];
5056
if (!this.cache.thinks) this.cache.thinks = {};
57+
if (!this.cache.tasks) this.cache.tasks = {};
5158
if (!this.cache.metadata) this.cache.metadata = {};
5259
if (!this.cache.nextId) this.cache.nextId = 1;
5360
let nextId = this.cache.nextId;
@@ -68,14 +75,34 @@ export class AgentFileSaver implements IAgentSaverService {
6875
}
6976
}
7077
this.cache.nextId = nextId;
78+
for (const entry of Object.values(this.cache.tasks)) {
79+
if (!entry.messages) entry.messages = [];
80+
if (!entry.metadata) entry.metadata = {};
81+
for (const m of entry.messages) {
82+
if (m.id == null) m.id = nextId++;
83+
else nextId = Math.max(nextId, m.id + 1);
84+
if (m.createdAt == null) m.createdAt = now;
85+
if (!m.kind) m.kind = MessageKind.Normal;
86+
}
87+
}
88+
this.cache.nextId = nextId;
7189
}
7290
} catch (error: any) {
7391
this.logger?.warn(`读取文件失败: ${error.message}`);
74-
this.cache = { messages: [], thinks: {}, metadata: {}, nextId: 1 };
92+
this.cache = { messages: [], thinks: {}, tasks: {}, metadata: {}, nextId: 1 };
7593
}
7694
return this.cache!;
7795
}
7896

97+
private getOrCreateTask(file: ThreadFile, taskId: string): TaskEntry {
98+
let entry = file.tasks[taskId];
99+
if (!entry) {
100+
entry = { messages: [], metadata: {} };
101+
file.tasks[taskId] = entry;
102+
}
103+
return entry;
104+
}
105+
79106
private async writeThreadFile(file: ThreadFile): Promise<void> {
80107
const dir = dirname(this.filePath);
81108
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
@@ -175,5 +202,68 @@ export class AgentFileSaver implements IAgentSaverService {
175202
await this.writeThreadFile(file);
176203
}
177204

205+
// --- Task scope ---
206+
207+
async getTaskMessages(taskId: string, includeAll = false): Promise<StoredMessage[]> {
208+
const file = await this.getFile();
209+
const entry = file.tasks[taskId];
210+
if (!entry) return [];
211+
return includeAll
212+
? [...entry.messages]
213+
: entry.messages.filter(m => m.kind === MessageKind.Normal);
214+
}
215+
216+
async pushTaskMessage(taskId: string, message: ChatMessage, options?: ChatMessageOptions): Promise<void> {
217+
const file = await this.getFile();
218+
const entry = this.getOrCreateTask(file, taskId);
219+
const id = file.nextId;
220+
file.nextId = id + 1;
221+
entry.messages.push({
222+
id,
223+
message,
224+
createdAt: Math.floor(Date.now() / 1000),
225+
thinkId: options?.thinkId,
226+
kind: options?.kind ?? MessageKind.Normal,
227+
});
228+
await this.writeThreadFile(file);
229+
}
230+
231+
async applyTaskCompaction(taskId: string, compactedIds: number[], summary: NewStoredMessage): Promise<void> {
232+
const file = await this.getFile();
233+
const entry = this.getOrCreateTask(file, taskId);
234+
const set = new Set(compactedIds);
235+
for (const m of entry.messages) {
236+
if (set.has(m.id)) m.kind = MessageKind.Archive;
237+
}
238+
const id = file.nextId;
239+
file.nextId = id + 1;
240+
entry.messages.push({
241+
...summary,
242+
id,
243+
createdAt: Math.floor(Date.now() / 1000),
244+
});
245+
await this.writeThreadFile(file);
246+
}
247+
248+
async clearTask(taskId: string): Promise<void> {
249+
const file = await this.getFile();
250+
if (file.tasks[taskId]) {
251+
delete file.tasks[taskId];
252+
await this.writeThreadFile(file);
253+
}
254+
}
255+
256+
async getTaskMetadata(taskId: string, key: string): Promise<string | undefined> {
257+
const file = await this.getFile();
258+
return file.tasks[taskId]?.metadata[key];
259+
}
260+
261+
async setTaskMetadata(taskId: string, key: string, value: string): Promise<void> {
262+
const file = await this.getFile();
263+
const entry = this.getOrCreateTask(file, taskId);
264+
entry.metadata[key] = value;
265+
await this.writeThreadFile(file);
266+
}
267+
178268
async dispose(): Promise<void> {}
179269
}

packages/scorpio.ai/src/Saver/AgentMemorySaver.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,31 @@ import {
77
MessageKind,
88
} from "./IAgentSaverService";
99

10+
interface TaskEntry {
11+
messages: StoredMessage[];
12+
metadata: Record<string, string>;
13+
}
14+
1015
/**
1116
* 纯内存实现的 AgentSaver,不持久化。
1217
* 适用于临时会话、单次任务或测试场景。
1318
*/
1419
export class AgentMemorySaver implements IAgentSaverService {
1520
private messages: StoredMessage[] = [];
1621
private thinks: Record<string, StoredMessage[]> = {};
22+
private tasks: Record<string, TaskEntry> = {};
1723
private metadata: Record<string, string> = {};
1824
private nextId = 1;
1925

26+
private getOrCreateTask(taskId: string): TaskEntry {
27+
let entry = this.tasks[taskId];
28+
if (!entry) {
29+
entry = { messages: [], metadata: {} };
30+
this.tasks[taskId] = entry;
31+
}
32+
return entry;
33+
}
34+
2035
async getAllMessages(includeAll = false): Promise<StoredMessage[]> {
2136
return includeAll
2237
? [...this.messages]
@@ -92,5 +107,53 @@ export class AgentMemorySaver implements IAgentSaverService {
92107
this.metadata[key] = value;
93108
}
94109

110+
// --- Task scope ---
111+
112+
async getTaskMessages(taskId: string, includeAll = false): Promise<StoredMessage[]> {
113+
const entry = this.tasks[taskId];
114+
if (!entry) return [];
115+
return includeAll
116+
? [...entry.messages]
117+
: entry.messages.filter(m => m.kind === MessageKind.Normal);
118+
}
119+
120+
async pushTaskMessage(taskId: string, message: ChatMessage, options?: ChatMessageOptions): Promise<void> {
121+
const entry = this.getOrCreateTask(taskId);
122+
entry.messages.push({
123+
id: this.nextId++,
124+
message,
125+
createdAt: Math.floor(Date.now() / 1000),
126+
thinkId: options?.thinkId,
127+
kind: options?.kind ?? MessageKind.Normal,
128+
});
129+
}
130+
131+
async applyTaskCompaction(taskId: string, compactedIds: number[], summary: NewStoredMessage): Promise<void> {
132+
const entry = this.getOrCreateTask(taskId);
133+
const set = new Set(compactedIds);
134+
for (const m of entry.messages) {
135+
if (set.has(m.id)) m.kind = MessageKind.Archive;
136+
}
137+
entry.messages.push({
138+
...summary,
139+
id: this.nextId++,
140+
createdAt: Math.floor(Date.now() / 1000),
141+
kind: summary.kind,
142+
});
143+
}
144+
145+
async clearTask(taskId: string): Promise<void> {
146+
delete this.tasks[taskId];
147+
}
148+
149+
async getTaskMetadata(taskId: string, key: string): Promise<string | undefined> {
150+
return this.tasks[taskId]?.metadata[key];
151+
}
152+
153+
async setTaskMetadata(taskId: string, key: string, value: string): Promise<void> {
154+
const entry = this.getOrCreateTask(taskId);
155+
entry.metadata[key] = value;
156+
}
157+
95158
async dispose(): Promise<void> {}
96159
}

0 commit comments

Comments
 (0)