@downcity/agent 1.1.202 → 1.1.204

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.
@@ -45,6 +45,27 @@ function create_mock_title_model(title_text) {
45
45
  });
46
46
  }
47
47
 
48
+ function create_failing_title_model() {
49
+ return new MockLanguageModelV3({
50
+ modelId: "mock-session-title-failing-model",
51
+ doGenerate: async () => {
52
+ throw new Error("mock title generation failed");
53
+ },
54
+ });
55
+ }
56
+
57
+ async function read_log_lines(agent_path) {
58
+ const logs_path = path.join(agent_path, ".downcity", "logs");
59
+ const entries = await fs.readdir(logs_path);
60
+ const lines = [];
61
+ for (const entry of entries) {
62
+ if (!entry.endsWith(".jsonl")) continue;
63
+ const content = await fs.readFile(path.join(logs_path, entry), "utf8");
64
+ lines.push(...content.split("\n").filter(Boolean));
65
+ }
66
+ return lines;
67
+ }
68
+
48
69
  test("Session keeps title empty when no model is available", async () => {
49
70
  const agent_path = await fs.mkdtemp(
50
71
  path.join(os.tmpdir(), "downcity-agent-session-title-"),
@@ -75,6 +96,51 @@ test("Session keeps title empty when no model is available", async () => {
75
96
  }
76
97
  });
77
98
 
99
+ test("Session logs title generation failure without blocking the session", async () => {
100
+ const agent_path = await fs.mkdtemp(
101
+ path.join(os.tmpdir(), "downcity-agent-session-title-log-"),
102
+ );
103
+ const agent = new Agent({
104
+ id: "title_log_agent",
105
+ path: agent_path,
106
+ model: create_failing_title_model(),
107
+ });
108
+ const session = await agent.sessions.create();
109
+
110
+ try {
111
+ await session.append_user_message({
112
+ text: "Diagnose why session title generation is flaky",
113
+ });
114
+
115
+ const records = await session.records();
116
+ assert.equal(records.session.title, undefined);
117
+
118
+ await agent.getLogger().saveAllLogs();
119
+ const log_lines = await read_log_lines(agent_path);
120
+ const title_failure_log = log_lines
121
+ .map((line) => JSON.parse(line))
122
+ .find((entry) => entry.message.includes("session_title.generate_failed"));
123
+
124
+ assert.ok(title_failure_log);
125
+ assert.equal(title_failure_log.type, "warn");
126
+ assert.equal(title_failure_log.details.sessionId, session.id);
127
+ assert.equal(
128
+ title_failure_log.details.modelLabel,
129
+ "mock-session-title-failing-model",
130
+ );
131
+ assert.equal(
132
+ title_failure_log.details.message,
133
+ "mock title generation failed",
134
+ );
135
+ assert.equal(
136
+ title_failure_log.details.firstUserTextLength,
137
+ "Diagnose why session title generation is flaky".length,
138
+ );
139
+ } finally {
140
+ await agent.dispose();
141
+ }
142
+ });
143
+
78
144
  test("Session retries title generation after model becomes available", async () => {
79
145
  const agent_path = await fs.mkdtemp(
80
146
  path.join(os.tmpdir(), "downcity-agent-session-title-retry-"),
@@ -120,6 +120,7 @@ export class Session implements AgentSession {
120
120
  history_store: this.historyStore,
121
121
  executor: this.executor,
122
122
  state: this.localState,
123
+ logger: this.logger,
123
124
  ensure_configured_hook: this.ensureConfiguredHook
124
125
  ? async () => {
125
126
  await this.ensureConfiguredHook?.(this);
@@ -142,6 +143,7 @@ export class Session implements AgentSession {
142
143
  session_id: this.id,
143
144
  history_store: this.historyStore,
144
145
  state_service: this.stateService,
146
+ logger: this.logger,
145
147
  is_executing: () => this.isExecuting(),
146
148
  get_instruction_system_blocks: this.getInstructionSystemBlocks,
147
149
  get_managed_plugin_system_blocks: this.getManagedPluginSystemBlocks,
@@ -11,6 +11,7 @@ import { generateText, type LanguageModel } from "ai";
11
11
  import type { SessionHistoryMetaV1 } from "@/executor/types/SessionHistoryMeta.js";
12
12
  import type { SessionRecordV1 } from "@/executor/types/SessionRecords.js";
13
13
  import { is_session_message_record } from "@/executor/types/SessionRecords.js";
14
+ import type { Logger } from "@/utils/logger/Logger.js";
14
15
  import {
15
16
  normalizeSessionTitle,
16
17
  readSessionMetadata,
@@ -48,6 +49,16 @@ export interface EnsureSessionTitleParams {
48
49
  */
49
50
  model?: LanguageModel;
50
51
 
52
+ /**
53
+ * 当前模型展示标签;仅用于排障日志,不参与生成逻辑。
54
+ */
55
+ modelLabel?: string;
56
+
57
+ /**
58
+ * 当前 session 运行日志器;标题生成失败时仅记录摘要,不影响主流程。
59
+ */
60
+ logger?: Logger;
61
+
51
62
  /**
52
63
  * 是否允许调用模型生成标题。
53
64
  */
@@ -102,16 +113,95 @@ function normalizeGeneratedTitle(input: string): string | undefined {
102
113
  : undefined;
103
114
  }
104
115
 
116
+ function summarizeTitleError(error: unknown): {
117
+ /**
118
+ * 错误对象名称。
119
+ */
120
+ name: string | null;
121
+
122
+ /**
123
+ * 错误消息摘要。
124
+ */
125
+ message: string | null;
126
+
127
+ /**
128
+ * 字符串化后的错误摘要。
129
+ */
130
+ error: string;
131
+ } {
132
+ const record =
133
+ error && typeof error === "object" && !Array.isArray(error)
134
+ ? (error as Record<string, unknown>)
135
+ : {};
136
+ return {
137
+ name: typeof record.name === "string" ? record.name : null,
138
+ message: typeof record.message === "string" ? record.message : null,
139
+ error: String(error),
140
+ };
141
+ }
142
+
143
+ async function logSessionTitleDiagnostic(input: {
144
+ /**
145
+ * 当前 session 标识。
146
+ */
147
+ sessionId: string;
148
+
149
+ /**
150
+ * 日志级别。
151
+ */
152
+ level: "debug" | "warn";
153
+
154
+ /**
155
+ * 日志消息。
156
+ */
157
+ message: string;
158
+
159
+ /**
160
+ * 结构化日志字段。
161
+ */
162
+ details: Record<string, string | number | boolean | null | undefined>;
163
+
164
+ /**
165
+ * 当前 session 运行日志器。
166
+ */
167
+ logger?: Logger;
168
+ }): Promise<void> {
169
+ if (!input.logger) return;
170
+ try {
171
+ await input.logger.log(input.level, input.message, {
172
+ sessionId: input.sessionId,
173
+ ...input.details,
174
+ });
175
+ } catch {
176
+ // 关键点(中文):标题诊断日志失败不能影响 session 主流程。
177
+ }
178
+ }
179
+
105
180
  async function generateSessionTitle(input: {
106
181
  /**
107
182
  * 当前模型实例。
108
183
  */
109
184
  model: LanguageModel;
110
185
 
186
+ /**
187
+ * 当前 session 标识。
188
+ */
189
+ sessionId: string;
190
+
191
+ /**
192
+ * 当前模型展示标签;仅用于排障日志。
193
+ */
194
+ modelLabel?: string;
195
+
111
196
  /**
112
197
  * 首条用户消息文本。
113
198
  */
114
199
  firstUserText: string;
200
+
201
+ /**
202
+ * 当前 session 运行日志器。
203
+ */
204
+ logger?: Logger;
115
205
  }): Promise<string | undefined> {
116
206
  try {
117
207
  const result = await generateText({
@@ -125,8 +215,33 @@ async function generateSessionTitle(input: {
125
215
  input.firstUserText,
126
216
  ].join("\n"),
127
217
  });
128
- return normalizeGeneratedTitle(result.text);
129
- } catch {
218
+ const generatedTitle = normalizeGeneratedTitle(result.text);
219
+ if (!generatedTitle) {
220
+ await logSessionTitleDiagnostic({
221
+ logger: input.logger,
222
+ sessionId: input.sessionId,
223
+ level: "warn",
224
+ message: "[agent] session_title.empty",
225
+ details: {
226
+ modelLabel: input.modelLabel || null,
227
+ firstUserTextLength: input.firstUserText.length,
228
+ rawTitleLength: String(result.text || "").length,
229
+ },
230
+ });
231
+ }
232
+ return generatedTitle;
233
+ } catch (error) {
234
+ await logSessionTitleDiagnostic({
235
+ logger: input.logger,
236
+ sessionId: input.sessionId,
237
+ level: "warn",
238
+ message: "[agent] session_title.generate_failed",
239
+ details: {
240
+ modelLabel: input.modelLabel || null,
241
+ firstUserTextLength: input.firstUserText.length,
242
+ ...summarizeTitleError(error),
243
+ },
244
+ });
130
245
  // 关键点(中文):标题生成失败不能影响 session 主流程。
131
246
  return undefined;
132
247
  }
@@ -142,13 +257,30 @@ export async function ensureSessionTitle(
142
257
  if (current.title) return current;
143
258
 
144
259
  const firstUserText = resolveFirstUserText(input.messages);
145
- if (input.generate !== true || !input.model || !firstUserText) {
260
+ if (input.generate !== true) {
261
+ return current;
262
+ }
263
+ if (!input.model || !firstUserText) {
264
+ await logSessionTitleDiagnostic({
265
+ logger: input.logger,
266
+ sessionId: input.sessionId,
267
+ level: "debug",
268
+ message: "[agent] session_title.skipped",
269
+ details: {
270
+ reason: !input.model ? "missing_model" : "missing_first_user_text",
271
+ modelLabel: input.modelLabel || null,
272
+ messageCount: input.messages.length,
273
+ },
274
+ });
146
275
  return current;
147
276
  }
148
277
 
149
278
  const generatedTitle = await generateSessionTitle({
150
279
  model: input.model,
280
+ sessionId: input.sessionId,
281
+ modelLabel: input.modelLabel,
151
282
  firstUserText,
283
+ logger: input.logger,
152
284
  });
153
285
  if (!generatedTitle) return current;
154
286
 
@@ -41,6 +41,7 @@ import type {
41
41
  import { to_session_action_record } from "@/executor/types/SessionRecords.js";
42
42
  import type { SessionLocalState } from "@/types/session/SessionLocalState.js";
43
43
  import { generateId } from "@/utils/Id.js";
44
+ import type { Logger } from "@/utils/logger/Logger.js";
44
45
 
45
46
  type SessionStateServiceOptions = {
46
47
  /**
@@ -73,6 +74,11 @@ type SessionStateServiceOptions = {
73
74
  */
74
75
  state: SessionLocalState;
75
76
 
77
+ /**
78
+ * 当前 session 运行日志器。
79
+ */
80
+ logger: Logger;
81
+
76
82
  /**
77
83
  * 在执行前补齐宿主级配置。
78
84
  */
@@ -107,6 +113,7 @@ export class SessionStateService {
107
113
  private readonly history_store: JsonlSessionHistoryStore;
108
114
  private readonly executor: Executor;
109
115
  private readonly state: SessionLocalState;
116
+ private readonly logger: Logger;
110
117
  private readonly ensure_configured_hook?: SessionStateServiceOptions["ensure_configured_hook"];
111
118
  private readonly publish_event: SessionStateServiceOptions["publish_event"];
112
119
 
@@ -117,6 +124,7 @@ export class SessionStateService {
117
124
  this.history_store = options.history_store;
118
125
  this.executor = options.executor;
119
126
  this.state = options.state;
127
+ this.logger = options.logger;
120
128
  this.ensure_configured_hook = options.ensure_configured_hook;
121
129
  this.publish_event = options.publish_event;
122
130
  }
@@ -226,9 +234,17 @@ export class SessionStateService {
226
234
  const next_model_label = input.model
227
235
  ? inferAgentModelLabel(input.model)
228
236
  : undefined;
237
+ const should_emit_model_switch_action = Boolean(
238
+ input.model &&
239
+ should_emit_action &&
240
+ this.state.sessionConfig.model &&
241
+ previous_model_label &&
242
+ next_model_label &&
243
+ previous_model_label !== next_model_label,
244
+ );
229
245
  const action_id = `model-switching:${this.session_id}:${Date.now()}:${generateId()}`;
230
246
 
231
- if (input.model && should_emit_action) {
247
+ if (should_emit_model_switch_action) {
232
248
  await this.emit_action_event({
233
249
  id: action_id,
234
250
  title: "Switching session model",
@@ -252,7 +268,7 @@ export class SessionStateService {
252
268
  model: this.state.sessionConfig.model,
253
269
  });
254
270
  } catch (error) {
255
- if (input.model && should_emit_action) {
271
+ if (should_emit_model_switch_action) {
256
272
  await this.emit_action_event({
257
273
  id: action_id,
258
274
  title: "Session model switch failed",
@@ -263,7 +279,7 @@ export class SessionStateService {
263
279
  throw error;
264
280
  }
265
281
 
266
- if (input.model && should_emit_action) {
282
+ if (should_emit_model_switch_action) {
267
283
  await this.emit_action_event({
268
284
  id: action_id,
269
285
  title: "Session model switched",
@@ -332,6 +348,10 @@ export class SessionStateService {
332
348
  sessionId: this.session_id,
333
349
  messages,
334
350
  ...(input?.generate ? { model: this.state.sessionConfig.model } : {}),
351
+ ...(this.state.sessionConfig.modelLabel
352
+ ? { modelLabel: this.state.sessionConfig.modelLabel }
353
+ : {}),
354
+ logger: this.logger,
335
355
  generate: input?.generate === true,
336
356
  });
337
357
  const next_title = String(next_metadata.title || "").trim();
@@ -31,6 +31,7 @@ import type {
31
31
  import type { SessionRecordV1 } from "@/executor/types/SessionRecords.js";
32
32
  import { SessionStateService } from "@/session/services/SessionStateService.js";
33
33
  import type { SessionRunContext } from "@/types/executor/SessionRunContext.js";
34
+ import type { Logger } from "@/utils/logger/Logger.js";
34
35
 
35
36
  type SessionViewServiceOptions<TSession extends Pick<AgentSession, "set">> = {
36
37
  /**
@@ -58,6 +59,11 @@ type SessionViewServiceOptions<TSession extends Pick<AgentSession, "set">> = {
58
59
  */
59
60
  state_service: SessionStateService;
60
61
 
62
+ /**
63
+ * 当前 session 运行日志器。
64
+ */
65
+ logger: Logger;
66
+
61
67
  /**
62
68
  * 判断当前 session 是否正在执行。
63
69
  */
@@ -106,6 +112,7 @@ export class SessionViewService<TSession extends Pick<AgentSession, "set">> {
106
112
  private readonly session_id: string;
107
113
  private readonly history_store: JsonlSessionHistoryStore;
108
114
  private readonly state_service: SessionStateService;
115
+ private readonly logger: Logger;
109
116
  private readonly is_executing: SessionViewServiceOptions<TSession>["is_executing"];
110
117
  private readonly get_instruction_system_blocks: SessionViewServiceOptions<TSession>["get_instruction_system_blocks"];
111
118
  private readonly get_managed_plugin_system_blocks: SessionViewServiceOptions<TSession>["get_managed_plugin_system_blocks"];
@@ -119,6 +126,7 @@ export class SessionViewService<TSession extends Pick<AgentSession, "set">> {
119
126
  this.session_id = options.session_id;
120
127
  this.history_store = options.history_store;
121
128
  this.state_service = options.state_service;
129
+ this.logger = options.logger;
122
130
  this.is_executing = options.is_executing;
123
131
  this.get_instruction_system_blocks = options.get_instruction_system_blocks;
124
132
  this.get_managed_plugin_system_blocks =
@@ -164,6 +172,7 @@ export class SessionViewService<TSession extends Pick<AgentSession, "set">> {
164
172
  agentId: this.agent_id,
165
173
  sessionId: this.session_id,
166
174
  messages: input.messages,
175
+ logger: this.logger,
167
176
  });
168
177
  return buildSessionInfo({
169
178
  projectRoot: this.project_root,