@downcity/agent 1.1.62 → 1.1.64
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/bin/model/CityModelAdapter.d.ts.map +1 -1
- package/bin/model/CityModelAdapter.js +129 -13
- package/bin/model/CityModelAdapter.js.map +1 -1
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +15 -1
- package/bin/session/Session.js.map +1 -1
- package/bin/types/sdk/AgentSessionEvent.d.ts +22 -1
- package/bin/types/sdk/AgentSessionEvent.d.ts.map +1 -1
- package/package.json +2 -2
- package/scripts/city-model-tool-loop.test.mjs +181 -0
- package/scripts/session-prompt-runtime.test.mjs +1 -1
- package/scripts/session-title-event.test.mjs +51 -0
- package/src/model/CityModelAdapter.ts +184 -15
- package/src/session/Session.ts +14 -1
- package/src/types/sdk/AgentSessionEvent.ts +25 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证 Session 标题会进入 subscribe 事件与 history session 信息。
|
|
3
|
+
*
|
|
4
|
+
* 关键点(中文)
|
|
5
|
+
* - 这里走编译后的公开 SDK,锁住调用方实际可见行为。
|
|
6
|
+
* - 使用 appendUserMessage 触发 fallback 标题,避免测试依赖真实模型。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
|
|
15
|
+
import { Agent } from "../bin/index.js";
|
|
16
|
+
|
|
17
|
+
test("Session publishes title event and exposes title in history", async () => {
|
|
18
|
+
const agent_path = await fs.mkdtemp(
|
|
19
|
+
path.join(os.tmpdir(), "downcity-agent-session-title-"),
|
|
20
|
+
);
|
|
21
|
+
const agent = new Agent({
|
|
22
|
+
id: "title_agent",
|
|
23
|
+
path: agent_path,
|
|
24
|
+
});
|
|
25
|
+
const session = await agent.createSession();
|
|
26
|
+
const events = [];
|
|
27
|
+
const unsubscribe = session.subscribe((event) => {
|
|
28
|
+
events.push(event);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await session.appendUserMessage({
|
|
33
|
+
text: "Use shell tools to inspect the current workspace",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const title_event = events.find((event) => event.type === "session-title");
|
|
37
|
+
assert.deepEqual(title_event, {
|
|
38
|
+
type: "session-title",
|
|
39
|
+
sessionId: session.id,
|
|
40
|
+
title: "Use shell tools to inspect the current workspace",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const history = await session.history();
|
|
44
|
+
assert.equal(
|
|
45
|
+
history.session.title,
|
|
46
|
+
"Use shell tools to inspect the current workspace",
|
|
47
|
+
);
|
|
48
|
+
} finally {
|
|
49
|
+
unsubscribe();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -34,6 +34,24 @@ type ProviderPromptMessage = {
|
|
|
34
34
|
|
|
35
35
|
type ProviderStreamController = ReadableStreamDefaultController<Record<string, unknown>>;
|
|
36
36
|
type ProviderContentPart = Record<string, unknown>;
|
|
37
|
+
type ProviderPromptRole = "system" | "user" | "assistant" | "tool";
|
|
38
|
+
|
|
39
|
+
type ProviderToolResultOutput = {
|
|
40
|
+
/**
|
|
41
|
+
* tool result 输出类型。
|
|
42
|
+
*/
|
|
43
|
+
type?: unknown;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* tool result 输出值。
|
|
47
|
+
*/
|
|
48
|
+
value?: unknown;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* tool 拒绝原因。
|
|
52
|
+
*/
|
|
53
|
+
reason?: unknown;
|
|
54
|
+
};
|
|
37
55
|
|
|
38
56
|
function normalizeFinishReason(input: unknown): {
|
|
39
57
|
unified: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
|
|
@@ -72,12 +90,72 @@ function fileUrlFromProviderPart(part: Record<string, unknown>): string {
|
|
|
72
90
|
return "";
|
|
73
91
|
}
|
|
74
92
|
|
|
75
|
-
function
|
|
93
|
+
function resolveProviderPromptRole(input: unknown): ProviderPromptRole {
|
|
94
|
+
return input === "system" || input === "user" || input === "assistant" || input === "tool"
|
|
95
|
+
? input
|
|
96
|
+
: "user";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeProviderToolResultOutput(
|
|
100
|
+
output: ProviderToolResultOutput,
|
|
101
|
+
): {
|
|
102
|
+
/**
|
|
103
|
+
* 归一后的 tool part 状态。
|
|
104
|
+
*/
|
|
105
|
+
state: "output-available" | "output-error";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* tool 成功输出。
|
|
109
|
+
*/
|
|
110
|
+
output?: unknown;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* tool 错误文本。
|
|
114
|
+
*/
|
|
115
|
+
errorText?: string;
|
|
116
|
+
} {
|
|
117
|
+
const outputType = typeof output.type === "string" ? output.type : "";
|
|
118
|
+
if (outputType === "json" || outputType === "text" || outputType === "content") {
|
|
119
|
+
return {
|
|
120
|
+
state: "output-available",
|
|
121
|
+
output: "value" in output ? output.value : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (outputType === "error-json") {
|
|
125
|
+
return {
|
|
126
|
+
state: "output-error",
|
|
127
|
+
errorText: stringifyToolInput("value" in output ? output.value : null),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (outputType === "error-text") {
|
|
131
|
+
return {
|
|
132
|
+
state: "output-error",
|
|
133
|
+
errorText: String(output.value ?? ""),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (outputType === "execution-denied") {
|
|
137
|
+
return {
|
|
138
|
+
state: "output-error",
|
|
139
|
+
errorText: String(output.reason ?? "tool execution denied"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
state: "output-available",
|
|
144
|
+
output,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function providerContentToUiParts(
|
|
149
|
+
content: unknown,
|
|
150
|
+
existingParts?: UIMessage["parts"],
|
|
151
|
+
): UIMessage["parts"] {
|
|
76
152
|
if (!Array.isArray(content)) {
|
|
77
153
|
return [{ type: "text", text: textFromProviderContent(content) }];
|
|
78
154
|
}
|
|
79
155
|
|
|
80
|
-
const parts: UIMessage["parts"] =
|
|
156
|
+
const parts: UIMessage["parts"] = Array.isArray(existingParts)
|
|
157
|
+
? [...existingParts]
|
|
158
|
+
: [];
|
|
81
159
|
for (const part of content) {
|
|
82
160
|
if (!part || typeof part !== "object") continue;
|
|
83
161
|
const record = part as Record<string, unknown>;
|
|
@@ -109,6 +187,55 @@ function providerContentToUiParts(content: unknown): UIMessage["parts"] {
|
|
|
109
187
|
input: record.input,
|
|
110
188
|
providerExecuted: Boolean(record.providerExecuted),
|
|
111
189
|
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (record.type === "tool-result") {
|
|
193
|
+
const toolCallId = String(record.toolCallId ?? "");
|
|
194
|
+
const toolName = String(record.toolName ?? "");
|
|
195
|
+
const normalizedOutput = normalizeProviderToolResultOutput(
|
|
196
|
+
(record.output as ProviderToolResultOutput | undefined) ?? {},
|
|
197
|
+
);
|
|
198
|
+
const existingPart = parts.find((item) => {
|
|
199
|
+
if (!item || typeof item !== "object") return false;
|
|
200
|
+
const toolPart = item as { toolCallId?: unknown };
|
|
201
|
+
return String(toolPart.toolCallId ?? "") === toolCallId;
|
|
202
|
+
}) as
|
|
203
|
+
| ({
|
|
204
|
+
toolCallId?: unknown;
|
|
205
|
+
toolName?: unknown;
|
|
206
|
+
input?: unknown;
|
|
207
|
+
providerExecuted?: unknown;
|
|
208
|
+
} & Record<string, unknown>)
|
|
209
|
+
| undefined;
|
|
210
|
+
const baseInput = existingPart?.input ?? null;
|
|
211
|
+
const baseToolName = String(existingPart?.toolName ?? toolName);
|
|
212
|
+
const nextPart = normalizedOutput.state === "output-available"
|
|
213
|
+
? {
|
|
214
|
+
type: "dynamic-tool" as const,
|
|
215
|
+
toolName: baseToolName,
|
|
216
|
+
toolCallId,
|
|
217
|
+
state: "output-available" as const,
|
|
218
|
+
input: baseInput,
|
|
219
|
+
output: normalizedOutput.output,
|
|
220
|
+
providerExecuted: false,
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
type: "dynamic-tool" as const,
|
|
224
|
+
toolName: baseToolName,
|
|
225
|
+
toolCallId,
|
|
226
|
+
state: "output-error" as const,
|
|
227
|
+
input: baseInput,
|
|
228
|
+
errorText: normalizedOutput.errorText ?? "tool_error",
|
|
229
|
+
providerExecuted: false,
|
|
230
|
+
};
|
|
231
|
+
if (existingPart) {
|
|
232
|
+
const index = parts.indexOf(existingPart as never);
|
|
233
|
+
if (index >= 0) {
|
|
234
|
+
parts[index] = nextPart;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
parts.push(nextPart);
|
|
112
239
|
}
|
|
113
240
|
}
|
|
114
241
|
return parts;
|
|
@@ -116,19 +243,36 @@ function providerContentToUiParts(content: unknown): UIMessage["parts"] {
|
|
|
116
243
|
|
|
117
244
|
function providerPromptToMessages(prompt: unknown): UIMessage[] {
|
|
118
245
|
if (!Array.isArray(prompt)) return [];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
246
|
+
const messages: UIMessage[] = [];
|
|
247
|
+
for (const [index, message] of prompt.entries()) {
|
|
248
|
+
if (!message || typeof message !== "object") continue;
|
|
249
|
+
const item = message as ProviderPromptMessage;
|
|
250
|
+
const role = resolveProviderPromptRole(item.role);
|
|
251
|
+
if (role === "tool") {
|
|
252
|
+
const lastAssistantMessage = [...messages]
|
|
253
|
+
.reverse()
|
|
254
|
+
.find((candidate) => candidate.role === "assistant");
|
|
255
|
+
if (lastAssistantMessage) {
|
|
256
|
+
lastAssistantMessage.parts = providerContentToUiParts(
|
|
257
|
+
item.content,
|
|
258
|
+
lastAssistantMessage.parts,
|
|
259
|
+
);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
messages.push({
|
|
126
263
|
id: `city-model-message-${String(index)}`,
|
|
127
|
-
role,
|
|
128
|
-
parts,
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
|
|
264
|
+
role: "assistant",
|
|
265
|
+
parts: providerContentToUiParts(item.content),
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
messages.push({
|
|
270
|
+
id: `city-model-message-${String(index)}`,
|
|
271
|
+
role,
|
|
272
|
+
parts: providerContentToUiParts(item.content),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return messages;
|
|
132
276
|
}
|
|
133
277
|
|
|
134
278
|
function providerOptionsToInput(options: Record<string, unknown>): CityModelInvokeInput {
|
|
@@ -161,15 +305,40 @@ function uiMessageToProviderContent(message: UIMessage): ProviderContentPart[] {
|
|
|
161
305
|
toolCallId?: unknown;
|
|
162
306
|
toolName?: unknown;
|
|
163
307
|
input?: unknown;
|
|
308
|
+
output?: unknown;
|
|
309
|
+
errorText?: unknown;
|
|
310
|
+
state?: unknown;
|
|
164
311
|
providerExecuted?: unknown;
|
|
165
312
|
};
|
|
166
|
-
|
|
313
|
+
const content: ProviderContentPart[] = [{
|
|
167
314
|
type: "tool-call",
|
|
168
315
|
toolCallId: String(toolPart.toolCallId ?? ""),
|
|
169
316
|
toolName: String(toolPart.toolName ?? ""),
|
|
170
317
|
input: stringifyToolInput(toolPart.input),
|
|
171
318
|
providerExecuted: Boolean(toolPart.providerExecuted),
|
|
172
319
|
}];
|
|
320
|
+
if (toolPart.state === "output-available") {
|
|
321
|
+
content.push({
|
|
322
|
+
type: "tool-result",
|
|
323
|
+
toolCallId: String(toolPart.toolCallId ?? ""),
|
|
324
|
+
toolName: String(toolPart.toolName ?? ""),
|
|
325
|
+
output: {
|
|
326
|
+
type: "json",
|
|
327
|
+
value: toolPart.output ?? null,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
} else if (toolPart.state === "output-error") {
|
|
331
|
+
content.push({
|
|
332
|
+
type: "tool-result",
|
|
333
|
+
toolCallId: String(toolPart.toolCallId ?? ""),
|
|
334
|
+
toolName: String(toolPart.toolName ?? ""),
|
|
335
|
+
output: {
|
|
336
|
+
type: "error-text",
|
|
337
|
+
value: String(toolPart.errorText ?? "tool_error"),
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return content;
|
|
173
342
|
}
|
|
174
343
|
return [];
|
|
175
344
|
});
|
package/src/session/Session.ts
CHANGED
|
@@ -581,7 +581,13 @@ export class Session implements AgentSession {
|
|
|
581
581
|
generate?: boolean;
|
|
582
582
|
}): Promise<void> {
|
|
583
583
|
const messages = await this.historyStore.list();
|
|
584
|
-
await
|
|
584
|
+
const beforeMetadata = await readSessionMetadata({
|
|
585
|
+
projectRoot: this.projectRoot,
|
|
586
|
+
agentId: this.agentId,
|
|
587
|
+
sessionId: this.id,
|
|
588
|
+
});
|
|
589
|
+
const beforeTitle = String(beforeMetadata.title || "").trim();
|
|
590
|
+
const nextMetadata = await ensureSessionTitle({
|
|
585
591
|
projectRoot: this.projectRoot,
|
|
586
592
|
agentId: this.agentId,
|
|
587
593
|
sessionId: this.id,
|
|
@@ -589,6 +595,13 @@ export class Session implements AgentSession {
|
|
|
589
595
|
...(input?.generate ? { model: this.sessionConfig.model } : {}),
|
|
590
596
|
generate: input?.generate === true,
|
|
591
597
|
});
|
|
598
|
+
const nextTitle = String(nextMetadata.title || "").trim();
|
|
599
|
+
if (!nextTitle || nextTitle === beforeTitle) return;
|
|
600
|
+
this.eventHub.publish({
|
|
601
|
+
type: "session-title",
|
|
602
|
+
sessionId: this.id,
|
|
603
|
+
title: nextTitle,
|
|
604
|
+
});
|
|
592
605
|
}
|
|
593
606
|
|
|
594
607
|
private async persistAssistantResult(
|
|
@@ -158,6 +158,30 @@ export interface AgentSessionAssistantStepEvent {
|
|
|
158
158
|
visibility?: SessionAssistantStepVisibility;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Session 标题更新事件。
|
|
163
|
+
*/
|
|
164
|
+
export interface AgentSessionTitleEvent {
|
|
165
|
+
/**
|
|
166
|
+
* 当前事件类型。
|
|
167
|
+
*/
|
|
168
|
+
type: "session-title";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 当前 session 唯一标识。
|
|
172
|
+
*/
|
|
173
|
+
sessionId: string;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 当前 session 最新标题。
|
|
177
|
+
*
|
|
178
|
+
* 说明(中文)
|
|
179
|
+
* - 标题已持久化到 session meta。
|
|
180
|
+
* - 新 session 通常在首条 user message 落盘后生成标题。
|
|
181
|
+
*/
|
|
182
|
+
title: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
/**
|
|
162
186
|
* 单个 turn 完成事件。
|
|
163
187
|
*/
|
|
@@ -213,6 +237,7 @@ export type AgentSessionEvent =
|
|
|
213
237
|
| AgentSessionToolCallEvent
|
|
214
238
|
| AgentSessionToolResultEvent
|
|
215
239
|
| AgentSessionAssistantStepEvent
|
|
240
|
+
| AgentSessionTitleEvent
|
|
216
241
|
| AgentSessionTurnFinishEvent
|
|
217
242
|
| AgentSessionErrorEvent;
|
|
218
243
|
|