@downcity/agent 1.1.63 → 1.1.66

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.
@@ -1,528 +1,53 @@
1
1
  /**
2
- * CityModel 到 AI SDK LanguageModel 的适配模块。
2
+ * CityModel 到 AI SDK LanguageModel 的归一化模块。
3
3
  *
4
4
  * 关键点(中文)
5
- * - Agent 对外可以接收 CityModel,但 executor 内部仍只处理 AI SDK LanguageModel。
6
- * - 适配逻辑集中在这里,避免 City 协议散落到 session/executor 各处。
5
+ * - Agent 对外可以接收 CityModel,但 executor 内部只处理 AI SDK LanguageModel。
6
+ * - CityModel 保留模型目录信息;运行时连接信息通过隐藏协议提供。
7
+ * - 这里直接创建 OpenAI-compatible LanguageModel,不再保留旧的 text/stream 反向适配。
7
8
  * - 这里不依赖 @downcity/city,只依赖 @downcity/type 的共享协议。
8
9
  */
9
10
 
11
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
10
12
  import {
11
13
  CITY_MODEL_INVOKER,
12
14
  isCityModel,
13
15
  type CityModel,
14
- type CityModelInvokeInput,
15
16
  } from "@downcity/type";
16
- import type { LanguageModel, UIMessage, UIMessageChunk } from "ai";
17
+ import type { LanguageModel } from "ai";
17
18
 
18
19
  /**
19
20
  * Agent SDK 可接受的模型输入。
20
21
  */
21
22
  export type AgentModel = LanguageModel | CityModel;
22
23
 
23
- type ProviderPromptMessage = {
24
- /**
25
- * 模型消息角色。
26
- */
27
- role?: string;
28
-
29
- /**
30
- * 模型消息内容。
31
- */
32
- content?: unknown;
33
- };
34
-
35
- type ProviderStreamController = ReadableStreamDefaultController<Record<string, unknown>>;
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
- };
55
-
56
- function normalizeFinishReason(input: unknown): {
57
- unified: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
58
- raw: string | undefined;
59
- } {
60
- const text = typeof input === "string" ? input : "";
61
- if (text === "stop" || text === "length" || text === "content-filter" || text === "tool-calls" || text === "error") {
62
- return { unified: text, raw: text };
63
- }
64
- return { unified: "stop", raw: text || "stop" };
65
- }
66
-
67
- function stringifyToolInput(input: unknown): string {
68
- if (typeof input === "string") return input;
69
- try {
70
- return JSON.stringify(input ?? {});
71
- } catch {
72
- return "{}";
73
- }
74
- }
75
-
76
- function textFromProviderContent(content: unknown): string {
77
- if (typeof content === "string") return content;
78
- if (!Array.isArray(content)) return "";
79
- return content
80
- .filter((part) => part && typeof part === "object" && (part as { type?: unknown }).type === "text")
81
- .map((part) => String((part as { text?: unknown }).text ?? ""))
82
- .join("\n")
83
- .trim();
84
- }
85
-
86
- function fileUrlFromProviderPart(part: Record<string, unknown>): string {
87
- const data = part.data;
88
- if (data instanceof URL) return data.toString();
89
- if (typeof data === "string") return data;
90
- return "";
91
- }
92
-
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"] {
152
- if (!Array.isArray(content)) {
153
- return [{ type: "text", text: textFromProviderContent(content) }];
154
- }
155
-
156
- const parts: UIMessage["parts"] = Array.isArray(existingParts)
157
- ? [...existingParts]
158
- : [];
159
- for (const part of content) {
160
- if (!part || typeof part !== "object") continue;
161
- const record = part as Record<string, unknown>;
162
- if (record.type === "text") {
163
- parts.push({ type: "text", text: String(record.text ?? "") });
164
- continue;
165
- }
166
- if (record.type === "reasoning") {
167
- parts.push({ type: "reasoning", text: String(record.text ?? "") });
168
- continue;
169
- }
170
- if (record.type === "file") {
171
- const url = fileUrlFromProviderPart(record);
172
- if (!url) continue;
173
- parts.push({
174
- type: "file",
175
- mediaType: String(record.mediaType ?? "application/octet-stream"),
176
- filename: typeof record.filename === "string" ? record.filename : undefined,
177
- url,
178
- });
179
- continue;
180
- }
181
- if (record.type === "tool-call") {
182
- parts.push({
183
- type: "dynamic-tool",
184
- toolName: String(record.toolName ?? ""),
185
- toolCallId: String(record.toolCallId ?? ""),
186
- state: "input-available",
187
- input: record.input,
188
- providerExecuted: Boolean(record.providerExecuted),
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);
239
- }
240
- }
241
- return parts;
242
- }
243
-
244
- function providerPromptToMessages(prompt: unknown): UIMessage[] {
245
- if (!Array.isArray(prompt)) return [];
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({
263
- id: `city-model-message-${String(index)}`,
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;
276
- }
277
-
278
- function providerOptionsToInput(options: Record<string, unknown>): CityModelInvokeInput {
279
- return {
280
- messages: providerPromptToMessages(options.prompt),
281
- tools: options.tools,
282
- toolChoice: options.toolChoice,
283
- providerOptions: options.providerOptions,
284
- };
285
- }
286
-
287
- function textFromUiMessage(message: UIMessage): string {
288
- return message.parts
289
- .filter((part) => part.type === "text")
290
- .map((part) => String((part as { text?: unknown }).text ?? ""))
291
- .join("\n")
292
- .trim();
293
- }
294
-
295
- function uiMessageToProviderContent(message: UIMessage): ProviderContentPart[] {
296
- return message.parts.flatMap((part): ProviderContentPart[] => {
297
- if (part.type === "text") {
298
- return [{ type: "text", text: String((part as { text?: unknown }).text ?? "") }];
299
- }
300
- if (part.type === "reasoning") {
301
- return [{ type: "reasoning", text: String((part as { text?: unknown }).text ?? "") }];
302
- }
303
- if (part.type === "dynamic-tool") {
304
- const toolPart = part as {
305
- toolCallId?: unknown;
306
- toolName?: unknown;
307
- input?: unknown;
308
- output?: unknown;
309
- errorText?: unknown;
310
- state?: unknown;
311
- providerExecuted?: unknown;
312
- };
313
- const content: ProviderContentPart[] = [{
314
- type: "tool-call",
315
- toolCallId: String(toolPart.toolCallId ?? ""),
316
- toolName: String(toolPart.toolName ?? ""),
317
- input: stringifyToolInput(toolPart.input),
318
- providerExecuted: Boolean(toolPart.providerExecuted),
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;
342
- }
343
- return [];
344
- });
345
- }
346
-
347
- function enqueueFinish(
348
- controller: ProviderStreamController,
349
- finishReason: unknown,
350
- ): void {
351
- controller.enqueue({
352
- type: "finish",
353
- finishReason: normalizeFinishReason(finishReason),
354
- usage: {
355
- inputTokens: {
356
- total: undefined,
357
- noCache: undefined,
358
- cacheRead: undefined,
359
- cacheWrite: undefined,
360
- },
361
- outputTokens: {
362
- total: undefined,
363
- text: undefined,
364
- reasoning: undefined,
365
- },
366
- },
367
- });
368
- }
369
-
370
- function enqueueProviderParts(
371
- controller: ProviderStreamController,
372
- parts: Record<string, unknown>[],
373
- state: {
374
- /**
375
- * 当前流是否已经发出 stream-start。
376
- */
377
- sawStart: boolean;
378
-
379
- /**
380
- * 当前流是否已经发出 finish。
381
- */
382
- sawFinish: boolean;
383
- },
384
- ): void {
385
- for (const part of parts) {
386
- if (part.type !== "stream-start" && !state.sawStart) {
387
- controller.enqueue({ type: "stream-start", warnings: [] });
388
- state.sawStart = true;
389
- }
390
- if (part.type === "stream-start") state.sawStart = true;
391
- if (part.type === "finish") state.sawFinish = true;
392
- controller.enqueue(part);
393
- }
394
- }
395
-
396
- function mapUiChunkToProviderParts(chunk: UIMessageChunk): ProviderContentPart[] {
397
- switch (chunk.type) {
398
- case "start":
399
- return [{ type: "stream-start", warnings: [] }];
400
- case "text-start":
401
- return [{ type: "text-start", id: chunk.id }];
402
- case "text-delta":
403
- return [{ type: "text-delta", id: chunk.id, delta: chunk.delta }];
404
- case "text-end":
405
- return [{ type: "text-end", id: chunk.id }];
406
- case "reasoning-start":
407
- return [{ type: "reasoning-start", id: chunk.id }];
408
- case "reasoning-delta":
409
- return [{ type: "reasoning-delta", id: chunk.id, delta: chunk.delta }];
410
- case "reasoning-end":
411
- return [{ type: "reasoning-end", id: chunk.id }];
412
- case "tool-input-start":
413
- return [{
414
- type: "tool-input-start",
415
- id: chunk.toolCallId,
416
- toolName: chunk.toolName,
417
- providerExecuted: chunk.providerExecuted,
418
- dynamic: chunk.dynamic,
419
- }];
420
- case "tool-input-delta":
421
- return [{
422
- type: "tool-input-delta",
423
- id: chunk.toolCallId,
424
- delta: chunk.inputTextDelta,
425
- }];
426
- case "tool-input-available":
427
- return [{
428
- type: "tool-call",
429
- toolCallId: chunk.toolCallId,
430
- toolName: chunk.toolName,
431
- input: stringifyToolInput(chunk.input),
432
- providerExecuted: chunk.providerExecuted,
433
- dynamic: chunk.dynamic,
434
- }];
435
- case "tool-output-available":
436
- return [{
437
- type: "tool-result",
438
- toolCallId: chunk.toolCallId,
439
- toolName: "",
440
- output: { type: "json", value: chunk.output },
441
- providerExecuted: chunk.providerExecuted,
442
- dynamic: chunk.dynamic,
443
- }];
444
- case "error":
445
- return [{ type: "error", error: new Error(chunk.errorText) }];
446
- default:
447
- return [];
448
- }
449
- }
450
-
24
+ /**
25
+ * 将 CityModel 的 hidden connection 转换为 AI SDK LanguageModel。
26
+ */
451
27
  function cityModelToLanguageModel(model: CityModel): LanguageModel {
452
- const invoker = model[CITY_MODEL_INVOKER];
453
- const languageModel = {
454
- specificationVersion: "v3",
455
- provider: "downcity",
456
- modelId: model.id,
457
- supportedUrls: {},
458
- async doGenerate(options) {
459
- const message = await invoker.text(providerOptionsToInput(options as Record<string, unknown>));
460
- return {
461
- content: uiMessageToProviderContent(message),
462
- finishReason: normalizeFinishReason("stop"),
463
- usage: {
464
- inputTokens: {
465
- total: undefined,
466
- noCache: undefined,
467
- cacheRead: undefined,
468
- cacheWrite: undefined,
469
- },
470
- outputTokens: {
471
- total: undefined,
472
- text: undefined,
473
- reasoning: undefined,
474
- },
475
- },
476
- response: {
477
- modelId: model.id,
478
- },
479
- warnings: [],
480
- };
481
- },
482
- async doStream(options) {
483
- const cityStream = await invoker.stream(providerOptionsToInput(options as Record<string, unknown>));
484
- return {
485
- stream: new ReadableStream({
486
- async start(controller: ProviderStreamController) {
487
- const reader = cityStream.getReader();
488
- const state = {
489
- sawStart: false,
490
- sawFinish: false,
491
- };
492
- try {
493
- while (true) {
494
- const { done, value } = await reader.read();
495
- if (done) break;
496
- const parts = mapUiChunkToProviderParts(value);
497
- enqueueProviderParts(controller, parts, state);
498
- }
499
- if (!state.sawStart) controller.enqueue({ type: "stream-start", warnings: [] });
500
- if (!state.sawFinish) enqueueFinish(controller, "stop");
501
- controller.close();
502
- } catch (error) {
503
- controller.enqueue({ type: "error", error });
504
- if (!state.sawFinish) enqueueFinish(controller, "error");
505
- controller.close();
506
- } finally {
507
- reader.releaseLock();
508
- }
509
- },
510
- }),
511
- response: {
512
- modelId: model.id,
513
- },
514
- };
515
- },
516
- };
517
-
518
- return languageModel as unknown as LanguageModel;
28
+ const connection = model[CITY_MODEL_INVOKER].connection();
29
+ const provider = createOpenAICompatible({
30
+ name: "downcity",
31
+ baseURL: connection.base_url,
32
+ apiKey: connection.api_key,
33
+ transformRequestBody: (body) => ({
34
+ ...body,
35
+ ...connection.request_body,
36
+ model: typeof body.model === "string" && body.model.trim()
37
+ ? body.model
38
+ : connection.model_id,
39
+ }),
40
+ });
41
+ return provider.languageModel(connection.model_id) as LanguageModel;
519
42
  }
520
43
 
521
44
  /**
522
45
  * 将 Agent 可接受的模型输入归一为 AI SDK LanguageModel。
523
46
  */
524
47
  export function normalizeAgentModel(model: AgentModel): LanguageModel {
525
- if (isCityModel(model)) return cityModelToLanguageModel(model);
48
+ if (isCityModel(model)) {
49
+ return cityModelToLanguageModel(model);
50
+ }
526
51
  return model;
527
52
  }
528
53
 
@@ -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 ensureSessionTitle({
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