@roll-agent/octopus-agent 0.0.11 → 0.1.1

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.
@@ -3,14 +3,13 @@ from __future__ import annotations
3
3
  import json
4
4
  import re
5
5
  import sys
6
- import time
7
6
  from dataclasses import asdict, is_dataclass
8
- from pathlib import Path
9
7
  from typing import Any, Callable
10
8
 
11
9
  from . import __version__
12
- from .agent import SpongeAgent
10
+ from .agent import SpongeAgent, ensure_query_plan_summary_answer
13
11
  from .config import AppConfig, load_config_from_env
12
+ from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
14
13
  from .schema_cache import SchemaCacheError
15
14
 
16
15
  SERVER_NAME = "octopus-agent"
@@ -20,10 +19,6 @@ PROTOCOL_VERSION = "2024-11-05"
20
19
  Json = dict[str, Any]
21
20
  AgentFactory = Callable[[], SpongeAgent]
22
21
  SamplerFactory = Callable[[str, str], str]
23
- _DEFAULT_CLARIFICATION_SESSION = "__default__"
24
- _PENDING_CLARIFICATIONS: dict[str, Json] = {}
25
- _CLARIFICATION_CACHE_PATH = Path.cwd() / "data/sponge-clarification-cache.json"
26
- _CLARIFICATION_CACHE_TTL_SECONDS = 10 * 60
27
22
 
28
23
 
29
24
  def main() -> None:
@@ -31,7 +26,12 @@ def main() -> None:
31
26
 
32
27
 
33
28
  def serve_stdio() -> None:
34
- session = StdioSession(sys.stdin.buffer, sys.stdout.buffer)
29
+ config = load_config_from_env()
30
+ session = StdioSession(
31
+ sys.stdin.buffer,
32
+ sys.stdout.buffer,
33
+ max_tokens=config.sampling.max_tokens,
34
+ )
35
35
  while True:
36
36
  message = session.read_message()
37
37
  if message is None:
@@ -80,7 +80,7 @@ def tool_definitions() -> list[Json]:
80
80
  return [
81
81
  {
82
82
  "name": "diagnostic_status",
83
- "description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
83
+ "description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,只调用本工具并在返回状态后停止,不得主动调用 query_sponge 或编造示例业务问题。普通业务查询不要先调用本工具,直接调用 query_sponge。",
84
84
  "inputSchema": {
85
85
  "type": "object",
86
86
  "properties": {},
@@ -104,7 +104,7 @@ def tool_definitions() -> list[Json]:
104
104
  },
105
105
  {
106
106
  "name": "query_sponge",
107
- "description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。上层模型必须直接调用本工具;不要先调用 diagnostic_status refresh_schema;query_sponge 返回“目前不支持该查询”后也不要自行 retry fastMode=false 或再调用 refresh_schema,除非用户明确要求调试;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL 和审计。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后必须把 content[0].text 或 structuredContent.answer 原文作为最终用户可见答复完整展示,最终消息必须直接包含完整业务答案;不要只放在折叠步骤、思考过程、工具详情或可折叠步骤里,不要自行改写成摘要;finalAnswerOnly=true(默认)时只能使用 structuredContent.answer 作为最终回复,不得改写、删减或追加内容;如果 structuredContent.needsClarification=true,必须立即停止,用户未确认前不得再次调用 query_sponge,不得自行选择品牌/项目/地点候选,不得把品牌+地点拼成项目名,必须原样展示 structuredContent.answer 并等待用户确认;用户确认品牌/项目后,不要重新发现工具,不要调用 diagnostic_status refresh_schema,不要解释 schema,不要自行判断“schema 不支持项目维度”,必须直接再次调用 query_sponge 并传 clarificationSelection;clarificationSelection 必须包含 baseQuestion、entityType、entityId、entityName,优先从 structuredContent.clarificationContext 构造,避免依赖进程内存。如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。如果 answer 开头包含“已识别并用于筛选:…”这类实体识别说明(说明系统已把口语/简称/错字归一为标准品牌、项目、地点并据此过滤),必须在最终回复中原样保留并展示这条说明,不得删除或改写,便于用户确认筛选口径。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim,便于上层 Agent 直接把 answer 展示给用户;复杂查询会使用 schema context 和 LLM 生成。",
107
+ "description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。只有用户给出明确业务数据查询问题时才调用本工具;用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,不得调用本工具,应调用 diagnostic_status 后停止,不得编造示例业务问题。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。工具会先按 schema 中文语义识别表、字段、筛选条件、枚举和实体,把 search_entities 的真实命中项固化进 queryPlan,再直接生成、校验并执行 SQL,不再要求用户确认查询计划;状态枚举不单独追问,用户未提出状态条件时首次查询保持宽泛,但查询结果必须展示相关状态字段的中文含义;search_entities 只用于实体别称和模糊召回,品牌/项目等多个实体同时命中时不追问用户,queryPlan 会用同组 OR 条件承载。上层模型必须直接调用本工具;不要自行生成 SQL;不要解释 schema;不要改写用户问题。originalQuestion 必须传用户原始话术。对报告类问题必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个子查询。工具返回后必须把 content[0].text 或 structuredContent.answer 原文作为最终用户可见答复完整展示;finalAnswerOnly=true(默认)时不得改写、删减、摘要、截断或追加内容,表格不得只展示前 N 条。content[0].text 只展示三段式用户可见答复(1.查询实体;2.筛选条件;3.查询结果);structuredContent.executionSteps 会返回每一步状态,失败时包含实际 error 原因。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim;复杂查询会使用 schema context 和 LLM 生成。",
108
108
  "inputSchema": {
109
109
  "type": "object",
110
110
  "required": ["question", "originalQuestion"],
@@ -120,7 +120,7 @@ def tool_definitions() -> list[Json]:
120
120
  },
121
121
  "finalAnswerOnly": {
122
122
  "type": "boolean",
123
- "description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为 verbatim 业务答复,上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
123
+ "description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为三段式 verbatim 答复:1.查询实体;2.筛选条件;3.查询结果。上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要、截断表格或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
124
124
  },
125
125
  "sessionId": {"type": "string", "description": "可选会话 ID"},
126
126
  "resultFormat": {
@@ -132,31 +132,6 @@ def tool_definitions() -> list[Json]:
132
132
  "type": "boolean",
133
133
  "description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
134
134
  },
135
- "clarificationSelection": {
136
- "type": "object",
137
- "required": ["entityType", "entityId"],
138
- "description": "用户已确认候选项时传入的结构化选择。未经用户确认不得传;无状态调用必须同时传 baseQuestion 和 entityName。",
139
- "properties": {
140
- "baseQuestion": {
141
- "type": "string",
142
- "description": "原始业务问题,优先使用上一轮 structuredContent.clarificationContext.baseQuestion",
143
- },
144
- "entityType": {
145
- "type": "string",
146
- "enum": ["brand", "project"],
147
- "description": "实体类型:brand=品牌,project=项目",
148
- },
149
- "entityId": {
150
- "type": ["integer", "string"],
151
- "description": "候选实体 ID,必须来自上一轮 clarificationPayload",
152
- },
153
- "entityName": {
154
- "type": "string",
155
- "description": "候选实体名称,必须来自上一轮 clarificationPayload",
156
- },
157
- },
158
- "additionalProperties": False,
159
- },
160
135
  },
161
136
  "additionalProperties": False,
162
137
  },
@@ -183,22 +158,16 @@ def call_tool(
183
158
  )
184
159
  )
185
160
  if tool_name == "query_sponge":
186
- agent = _make_agent(agent_factory)
187
161
  question = _required_string(arguments, "question")
188
162
  original_question = _required_string(arguments, "originalQuestion")
189
163
  session_id = arguments.get("sessionId")
190
- resolved_question = _resolve_pending_clarification(
164
+ blocked_reason = _blocked_query_call_reason(
191
165
  question=question,
192
166
  original_question=original_question,
193
- session_id=session_id,
194
- clarification_selection=arguments.get("clarificationSelection"),
195
167
  )
196
- if resolved_question is not None:
197
- question = resolved_question
198
- original_question = resolved_question
199
- _forget_pending_clarification(session_id)
200
- elif not _is_clarification_reply(original_question):
201
- _forget_pending_clarification(session_id)
168
+ if blocked_reason is not None:
169
+ return _tool_blocked_query(blocked_reason)
170
+ agent = _make_agent(agent_factory)
202
171
  answer_kwargs = {
203
172
  "question": question,
204
173
  "original_question": original_question,
@@ -210,15 +179,10 @@ def call_tool(
210
179
  if sampler is not None:
211
180
  answer_kwargs["sampler"] = sampler
212
181
  query_result = agent.query(**answer_kwargs)
213
- _sync_pending_clarification(
214
- query_result,
215
- question=question,
216
- original_question=original_question,
217
- session_id=session_id,
218
- )
219
182
  base_question = original_question or question
220
183
  if arguments.get("includeSql") is True:
221
184
  payload = _query_result_payload(query_result, include_sql=True, base_question=base_question)
185
+ payload["answer"] = _user_visible_answer(query_result, base_question=base_question)
222
186
  return _tool_json(payload, structured_content=payload)
223
187
  if final_answer_only:
224
188
  return _tool_final_answer(query_result, base_question=base_question)
@@ -254,22 +218,23 @@ def _diagnostic_status(config: AppConfig) -> Json:
254
218
  "enabled": config.audit.enabled,
255
219
  "logPath": str(config.audit.log_path),
256
220
  },
221
+ "sampling": {
222
+ "maxTokens": config.sampling.max_tokens,
223
+ },
257
224
  "binding": _binding_status(config),
258
225
  }
259
226
 
260
227
 
261
228
  def _binding_status(config: AppConfig) -> Json:
262
229
  binding = config.binding
263
- dictionary = config.dictionary
264
230
  return {
265
- "configured": binding is not None,
231
+ "configured": False,
232
+ "mode": "disabled",
233
+ "entitySource": "schema",
234
+ "dictionaryPersisted": False,
266
235
  "shadowMode": bool(binding.shadow_mode) if binding is not None else None,
267
236
  "enabledEntityTypes": list(binding.enabled_entity_types) if binding is not None else [],
268
237
  "minFuzzyScore": binding.min_fuzzy_score if binding is not None else None,
269
- "dictionaryCachePath": str(dictionary.cache_path) if dictionary is not None else None,
270
- "dictionaryCacheExists": dictionary.cache_path.exists() if dictionary is not None else False,
271
- "geoAliasPath": str(dictionary.geo_alias_path) if dictionary is not None else None,
272
- "geoAliasExists": dictionary.geo_alias_path.exists() if dictionary is not None else False,
273
238
  }
274
239
 
275
240
 
@@ -326,15 +291,6 @@ def _required_string(arguments: Json, key: str) -> str:
326
291
  return value
327
292
 
328
293
 
329
- def _optional_string(arguments: Json, key: str) -> str | None:
330
- value = arguments.get(key)
331
- if value is None:
332
- return None
333
- if not isinstance(value, str):
334
- raise ValueError(f"{key} must be a string")
335
- return value or None
336
-
337
-
338
294
  def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
339
295
  value = arguments.get(key)
340
296
  if value is None:
@@ -351,230 +307,135 @@ def _required_int(arguments: Json, key: str) -> int:
351
307
  return value
352
308
 
353
309
 
354
- def _clarification_key(session_id: Any) -> str:
355
- if isinstance(session_id, str) and session_id.strip():
356
- return session_id
357
- return _DEFAULT_CLARIFICATION_SESSION
358
-
359
-
360
- def _forget_pending_clarification(session_id: Any) -> None:
361
- _PENDING_CLARIFICATIONS.pop(_clarification_key(session_id), None)
362
- _delete_cached_clarification(session_id)
310
+ _PRESERVATION_SIGNAL_PATTERN = re.compile(
311
+ r"(今天|今日|昨天|昨日|明天|明日|本周|上周|本月|上月|最近\d+天"
312
+ r"|招满|已招满|在招|上架|下架|新发布|新上架"
313
+ r"|\d{4}年\d{1,2}月|\d{1,2}月\d{1,2}日"
314
+ r"|\d+日)"
315
+ )
316
+ _PRESERVATION_SIGNAL_EQUIVALENTS: dict[str, tuple[str, ...]] = {
317
+ "今天": ("今天", "今日"),
318
+ "今日": ("今天", "今日"),
319
+ "昨天": ("昨天", "昨日"),
320
+ "昨日": ("昨天", "昨日"),
321
+ "招满": ("招满", "已招满"),
322
+ "已招满": ("招满", "已招满"),
323
+ }
324
+ _REWRITTEN_SUMMARY_MARKERS = (
325
+ "相关的统计数据",
326
+ "相关统计数据",
327
+ "相关的统计",
328
+ "查询相关数据",
329
+ "查询相关统计",
330
+ )
331
+
332
+
333
+ def _extract_preservation_signals(text: str) -> list[str]:
334
+ return list(dict.fromkeys(match.group(0) for match in _PRESERVATION_SIGNAL_PATTERN.finditer(text)))
335
+
336
+
337
+ def _signal_preserved(signal: str, text: str) -> bool:
338
+ candidates = _PRESERVATION_SIGNAL_EQUIVALENTS.get(signal, (signal,))
339
+ return any(candidate in text for candidate in candidates)
340
+
341
+
342
+ def _missing_preservation_signals(*, source: str, target: str) -> list[str]:
343
+ missing: list[str] = []
344
+ for signal in _extract_preservation_signals(source):
345
+ if not _signal_preserved(signal, target):
346
+ missing.append(signal)
347
+ return missing
348
+
349
+
350
+ def _looks_like_rewritten_summary(text: str) -> bool:
351
+ normalized = text.strip()
352
+ if len(normalized) > 40 or _extract_preservation_signals(normalized):
353
+ return False
354
+ if not re.search(r"(查询|统计)", normalized):
355
+ return False
356
+ return any(marker in normalized for marker in _REWRITTEN_SUMMARY_MARKERS)
363
357
 
364
358
 
365
- def _load_pending_clarification(session_id: Any) -> Json | None:
366
- key = _clarification_key(session_id)
367
- cached = _read_clarification_cache().get(key)
368
- if not isinstance(cached, dict):
369
- return None
370
- updated_at = cached.get("updatedAt")
371
- if not isinstance(updated_at, (int, float)) or time.time() - float(updated_at) > _CLARIFICATION_CACHE_TTL_SECONDS:
372
- _delete_cached_clarification(session_id)
373
- return None
374
- question = cached.get("question")
375
- payload = cached.get("payload")
376
- if not isinstance(question, str) or not isinstance(payload, dict):
377
- _delete_cached_clarification(session_id)
359
+ def _blocked_original_question_reason(*, question: str, original_question: str) -> str | None:
360
+ q = question.strip()
361
+ oq = original_question.strip()
362
+ if not q or not oq:
378
363
  return None
379
- pending = {"question": question, "payload": payload}
380
- _PENDING_CLARIFICATIONS[key] = pending
381
- return pending
382
-
383
-
384
- def _save_pending_clarification(session_id: Any, pending: Json) -> None:
385
- question = pending.get("question")
386
- payload = pending.get("payload")
387
- if not isinstance(question, str) or not isinstance(payload, dict):
388
- return
389
- items = _read_clarification_cache()
390
- items[_clarification_key(session_id)] = {
391
- "updatedAt": time.time(),
392
- "question": question,
393
- "payload": payload,
394
- }
395
- _write_clarification_cache(items)
396
-
397
-
398
- def _delete_cached_clarification(session_id: Any) -> None:
399
- items = _read_clarification_cache()
400
- key = _clarification_key(session_id)
401
- if key not in items:
402
- return
403
- items.pop(key, None)
404
- _write_clarification_cache(items)
405
-
406
-
407
- def _read_clarification_cache() -> dict[str, Json]:
408
- try:
409
- if not _CLARIFICATION_CACHE_PATH.exists():
410
- return {}
411
- data = json.loads(_CLARIFICATION_CACHE_PATH.read_text(encoding="utf-8"))
412
- except (OSError, json.JSONDecodeError):
413
- return {}
414
- if not isinstance(data, dict):
415
- return {}
416
- items = data.get("items")
417
- return items if isinstance(items, dict) else {}
418
-
419
-
420
- def _write_clarification_cache(items: dict[str, Json]) -> None:
421
- try:
422
- if items:
423
- _CLARIFICATION_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
424
- _CLARIFICATION_CACHE_PATH.write_text(
425
- json.dumps({"items": items}, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
426
- encoding="utf-8",
364
+ if q == oq and _looks_like_rewritten_summary(oq):
365
+ return (
366
+ "originalQuestion 疑似上层摘要改写(例如“查询岗位相关的统计数据”),"
367
+ "请原样传入用户原始话术,不要摘要、翻译或改写。"
368
+ )
369
+ if q != oq:
370
+ missing = _missing_preservation_signals(source=q, target=oq)
371
+ if missing:
372
+ joined = "、".join(missing)
373
+ return (
374
+ f"originalQuestion 未保留 question 中的关键语义({joined})。"
375
+ "originalQuestion 必须传用户原始话术,上层不得摘要、翻译或改写。"
427
376
  )
428
- elif _CLARIFICATION_CACHE_PATH.exists():
429
- _CLARIFICATION_CACHE_PATH.unlink()
430
- except OSError:
431
- return
377
+ return None
432
378
 
433
379
 
434
- def _sync_pending_clarification(
435
- query_result: Any,
436
- *,
437
- question: str,
438
- original_question: str,
439
- session_id: Any,
440
- ) -> None:
441
- payload = getattr(query_result, "clarification_payload", None)
442
- if (
443
- bool(getattr(query_result, "needs_clarification", False))
444
- and getattr(query_result, "clarification_type", None) == "entity_resolution"
445
- and isinstance(payload, dict)
446
- and (payload.get("brands") or payload.get("projects"))
447
- ):
448
- pending = {
449
- "question": original_question or question,
450
- "payload": _jsonable(payload),
451
- }
452
- _PENDING_CLARIFICATIONS[_clarification_key(session_id)] = pending
453
- _save_pending_clarification(session_id, pending)
454
- return
455
- _forget_pending_clarification(session_id)
380
+ def _blocked_query_call_reason(*, question: str, original_question: str) -> str | None:
381
+ text = (original_question or question or "").strip()
382
+ if not text:
383
+ return "请先输入明确的业务数据查询需求,例如:查询我能看到的品牌列表。"
384
+ if _is_agent_meta_request(text) and not _has_explicit_business_object(text):
385
+ return "请先输入明确的业务数据查询需求;检查或测试丸子Agent时只允许调用 diagnostic_status。"
386
+ rewrite_reason = _blocked_original_question_reason(question=question, original_question=original_question)
387
+ if rewrite_reason is not None:
388
+ return rewrite_reason
389
+ return None
456
390
 
457
391
 
458
- def _resolve_pending_clarification(
459
- *,
460
- question: str,
461
- original_question: str,
462
- session_id: Any,
463
- clarification_selection: Any = None,
464
- ) -> str | None:
465
- pending = _PENDING_CLARIFICATIONS.get(_clarification_key(session_id)) or _load_pending_clarification(session_id)
466
- if clarification_selection is not None:
467
- selected = None
468
- if pending is not None:
469
- selected = _select_structured_clarification_candidate(clarification_selection, pending.get("payload"))
470
- if selected is None:
471
- return _stateless_clarification_question(clarification_selection)
472
- else:
473
- text = original_question or question
474
- if not _is_clarification_reply(text):
475
- return None
476
- if pending is None:
477
- return None
478
- selected = _select_clarification_candidate(text, pending.get("payload"))
479
- if selected is None:
480
- return None
481
- entity_type, candidate = selected
482
- label = "品牌" if entity_type == "brand" else "项目"
483
- name = str(candidate.get("name") or "")
484
- entity_id = candidate.get("id")
485
- base_question = str(pending.get("question") or question)
486
- if entity_id is None:
487
- return f"{base_question},按{label}「{name}」查询"
488
- return f"{base_question},按{label}「{name}」(id: {entity_id})查询"
489
-
490
-
491
- def _is_clarification_reply(text: str) -> bool:
492
- cleaned = _normalize_clarification_reply(text)
493
- if len(cleaned) > 30:
494
- return False
392
+ def _is_agent_meta_request(text: str) -> bool:
495
393
  return bool(
496
- re.fullmatch(r"(?:按)?(?:品牌|项目)(?:查询)?(?:id)?\d*", cleaned)
497
- or re.fullmatch(r"(?:品牌|项目)\d+", cleaned)
394
+ re.search(r"(丸子|octopus|agent|roll[- ]?core|工具|技能)", text, flags=re.IGNORECASE)
395
+ and re.search(r"(使用|启动|检查|看看|查看|测试|尝试|状态|健康|诊断|可用|功能)", text)
498
396
  )
499
397
 
500
398
 
501
- def _normalize_clarification_reply(text: str) -> str:
502
- return re.sub(r"[\s::,,。;;()()「」“”\"'《》【】]+", "", text or "").lower()
503
-
504
-
505
- def _select_clarification_candidate(text: str, payload: Any) -> tuple[str, Json] | None:
506
- if not isinstance(payload, dict):
507
- return None
508
- cleaned = _normalize_clarification_reply(text)
509
- wants_brand = "品牌" in cleaned
510
- wants_project = "项目" in cleaned
511
- if wants_brand == wants_project:
512
- return None
513
- entity_type = "brand" if wants_brand else "project"
514
- candidates = payload.get("brands" if entity_type == "brand" else "projects")
515
- if not isinstance(candidates, list) or not candidates:
516
- return None
517
- id_match = re.search(r"(?<!\d)(\d+)(?!\d)", cleaned)
518
- if id_match is not None:
519
- wanted_id = id_match.group(1)
520
- for candidate in candidates:
521
- if isinstance(candidate, dict) and str(candidate.get("id")) == wanted_id:
522
- return entity_type, candidate
523
- return None
524
- for candidate in candidates:
525
- if isinstance(candidate, dict):
526
- return entity_type, candidate
527
- return None
528
-
529
-
530
- def _select_structured_clarification_candidate(selection: Any, payload: Any) -> tuple[str, Json] | None:
531
- if not isinstance(selection, dict):
532
- raise ValueError("clarificationSelection must be an object")
533
- if not isinstance(payload, dict):
534
- return None
535
- entity_type = selection.get("entityType")
536
- if entity_type not in {"brand", "project"}:
537
- raise ValueError("clarificationSelection.entityType must be brand or project")
538
- if "entityId" not in selection:
539
- raise ValueError("clarificationSelection.entityId is required")
540
- candidates = payload.get("brands" if entity_type == "brand" else "projects")
541
- if not isinstance(candidates, list) or not candidates:
542
- return None
543
- wanted_id = str(selection.get("entityId"))
544
- for candidate in candidates:
545
- if isinstance(candidate, dict) and str(candidate.get("id")) == wanted_id:
546
- return entity_type, candidate
547
- return None
548
-
549
-
550
- def _stateless_clarification_question(selection: Any) -> str:
551
- if not isinstance(selection, dict):
552
- raise ValueError("clarificationSelection must be an object")
553
- entity_type = selection.get("entityType")
554
- if entity_type not in {"brand", "project"}:
555
- raise ValueError("clarificationSelection.entityType must be brand or project")
556
- if "entityId" not in selection:
557
- raise ValueError("clarificationSelection.entityId is required")
558
- base_question = _required_selection_string(selection, "baseQuestion")
559
- entity_name = _required_selection_string(selection, "entityName")
560
- entity_id = selection.get("entityId")
561
- label = "品牌" if entity_type == "brand" else "项目"
562
- return f"{base_question},按{label}「{entity_name}」(id: {entity_id})查询"
399
+ def _has_explicit_business_object(text: str) -> bool:
400
+ return bool(
401
+ re.search(
402
+ r"(品牌|项目|岗位|门店|工单|报名|入职|候选人|城市|省份|区域|供应商|员工|薪资|招聘|数据|列表|明细|数量|多少|哪些|有哪些)",
403
+ text,
404
+ )
405
+ )
563
406
 
564
407
 
565
- def _required_selection_string(selection: Json, key: str) -> str:
566
- value = selection.get(key)
567
- if not isinstance(value, str) or not value.strip():
568
- raise ValueError(f"clarificationSelection.{key} is required for stateless clarification")
569
- return value.strip()
408
+ def _tool_blocked_query(answer: str) -> Json:
409
+ payload: Json = {
410
+ "answer": answer,
411
+ "needsClarification": False,
412
+ "clarificationType": None,
413
+ "clarificationPayload": None,
414
+ "finalAnswerOnly": True,
415
+ "responseMode": "verbatim",
416
+ "blocked": True,
417
+ "blockedReason": answer,
418
+ }
419
+ return {
420
+ "content": _answer_content(answer),
421
+ "structuredContent": payload,
422
+ }
570
423
 
571
424
 
572
425
  def _tool_text(text: str) -> Json:
573
426
  return {"content": [{"type": "text", "text": text}]}
574
427
 
575
428
 
429
+ def _answer_content(answer: str) -> list[Json]:
430
+ return [
431
+ {"type": "text", "text": answer},
432
+ ]
433
+
434
+
576
435
  def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -> Json:
577
436
  payload = _query_result_payload(query_result, base_question=base_question)
437
+ answer = _user_visible_answer(query_result, base_question=base_question)
438
+ payload["answer"] = answer
578
439
  payload.update(
579
440
  {
580
441
  "finalAnswerOnly": True,
@@ -582,7 +443,7 @@ def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -
582
443
  }
583
444
  )
584
445
  return {
585
- "content": [{"type": "text", "text": str(getattr(query_result, "answer", ""))}],
446
+ "content": _answer_content(answer),
586
447
  "structuredContent": payload,
587
448
  }
588
449
 
@@ -591,8 +452,10 @@ def _tool_query_answer(query_result: Any, *, final_answer_only: bool, base_quest
591
452
  payload = _query_result_payload(query_result, base_question=base_question)
592
453
  payload["finalAnswerOnly"] = final_answer_only
593
454
  payload["responseMode"] = "verbatim"
455
+ answer = _user_visible_answer(query_result, base_question=base_question)
456
+ payload["answer"] = answer
594
457
  return {
595
- "content": [{"type": "text", "text": str(getattr(query_result, "answer", ""))}],
458
+ "content": _answer_content(answer),
596
459
  "structuredContent": payload,
597
460
  }
598
461
 
@@ -604,9 +467,12 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
604
467
  "clarificationType": getattr(query_result, "clarification_type", None),
605
468
  "clarificationPayload": getattr(query_result, "clarification_payload", None),
606
469
  }
607
- clarification_context = _clarification_context(payload["clarificationPayload"], base_question)
608
- if clarification_context is not None:
609
- payload["clarificationContext"] = clarification_context
470
+ query_plan = getattr(query_result, "query_plan", None)
471
+ if isinstance(query_plan, dict):
472
+ payload["queryPlan"] = _public_query_plan(query_plan)
473
+ execution_steps = getattr(query_result, "execution_steps", None)
474
+ if isinstance(execution_steps, list):
475
+ payload["executionSteps"] = execution_steps
610
476
  if include_sql:
611
477
  payload.update(
612
478
  {
@@ -619,26 +485,140 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
619
485
  return _jsonable(payload)
620
486
 
621
487
 
622
- def _clarification_context(clarification_payload: Any, base_question: str | None) -> Json | None:
623
- if not isinstance(clarification_payload, dict) or not base_question:
624
- return None
625
- brands = clarification_payload.get("brands")
626
- projects = clarification_payload.get("projects")
627
- if not brands and not projects:
628
- return None
488
+ def _public_query_plan(query_plan: dict[str, Any]) -> Json:
629
489
  return {
630
- "baseQuestion": base_question,
631
- "brands": brands or [],
632
- "projects": projects or [],
633
- "selectionFields": {
634
- "baseQuestion": "原始业务问题",
635
- "entityType": "实体类型:brand=品牌,project=项目",
636
- "entityId": "候选实体 ID",
637
- "entityName": "候选实体名称",
638
- },
490
+ key: value
491
+ for key, value in query_plan.items()
492
+ if not str(key).startswith("_")
639
493
  }
640
494
 
641
495
 
496
+ def _user_visible_answer(query_result: Any, *, base_question: str | None = None) -> str:
497
+ answer = str(getattr(query_result, "answer", ""))
498
+ question = base_question or ""
499
+ result_format = getattr(query_result, "result_format", None)
500
+ answer = _ensure_complete_detail_answer(query_result, answer, question, result_format)
501
+ query_plan = getattr(query_result, "query_plan", None)
502
+ return _ensure_two_section_answer(answer, question, result_format, query_plan)
503
+
504
+
505
+ def _ensure_two_section_answer(
506
+ answer: str,
507
+ question: str,
508
+ result_format: str | None,
509
+ query_plan: Any,
510
+ ) -> str:
511
+ if isinstance(query_plan, dict) and query_plan:
512
+ return ensure_query_plan_summary_answer(answer, question, result_format, query_plan)
513
+ if _answer_has_two_section_structure(answer, question, result_format):
514
+ return answer
515
+ return _prepend_missing_query_plan_section(answer, question, result_format)
516
+
517
+
518
+ def _answer_has_two_section_structure(answer: str, question: str, result_format: str | None) -> bool:
519
+ output_format = normalize_result_format(question, result_format)
520
+ if output_format == "json":
521
+ try:
522
+ payload = json.loads(answer)
523
+ except json.JSONDecodeError:
524
+ return False
525
+ return (
526
+ isinstance(payload, dict)
527
+ and "查询实体" in payload
528
+ and "筛选条件" in payload
529
+ and "查询结果" in payload
530
+ )
531
+ if output_format == "yaml":
532
+ return "查询实体:" in answer and "筛选条件:" in answer and "查询结果:" in answer
533
+ return "1. 查询实体" in answer and "2. 筛选条件" in answer and "3. 查询结果" in answer
534
+
535
+
536
+ def _prepend_missing_query_plan_section(answer: str, question: str, result_format: str | None) -> str:
537
+ output_format = normalize_result_format(question, result_format)
538
+ if output_format == "json":
539
+ try:
540
+ result_payload: Any = json.loads(answer)
541
+ except json.JSONDecodeError:
542
+ result_payload = answer
543
+ return json.dumps(
544
+ {
545
+ "查询实体": ["未返回查询计划"],
546
+ "筛选条件": ["未返回查询计划"],
547
+ "查询结果": result_payload,
548
+ },
549
+ ensure_ascii=False,
550
+ indent=2,
551
+ ) + "\n"
552
+ if output_format == "yaml":
553
+ return (
554
+ "查询实体:\n"
555
+ " - 未返回查询计划\n"
556
+ "筛选条件:\n"
557
+ " - 未返回查询计划\n"
558
+ "查询结果:\n"
559
+ + "\n".join(f" {line}" if line else "" for line in answer.rstrip().splitlines())
560
+ + "\n"
561
+ )
562
+ if output_format == "html":
563
+ return (
564
+ "<section><p>1. 查询实体:</p><p>未返回查询计划</p></section>"
565
+ "<section><p>2. 筛选条件:</p><p>未返回查询计划</p></section>"
566
+ "<section><p>3. 查询结果:</p>"
567
+ + answer
568
+ + "</section>"
569
+ )
570
+ return (
571
+ "1. 查询实体:\n"
572
+ "未返回查询计划\n\n"
573
+ "2. 筛选条件:\n"
574
+ "未返回查询计划\n\n"
575
+ "3. 查询结果:\n"
576
+ f"{answer}"
577
+ )
578
+
579
+
580
+ def _ensure_complete_detail_answer(
581
+ query_result: Any,
582
+ answer: str,
583
+ question: str,
584
+ result_format: str | None,
585
+ ) -> str:
586
+ raw_result = getattr(query_result, "raw_result", None)
587
+ if not isinstance(raw_result, dict):
588
+ return answer
589
+ rows = raw_result.get("rows")
590
+ if not isinstance(rows, list):
591
+ return answer
592
+ display_count = min(len(rows), RESULT_ROW_DISPLAY_LIMIT)
593
+ if display_count <= 0:
594
+ return answer
595
+ output_format = normalize_result_format(question, result_format)
596
+ if output_format in {"text", "markdown"}:
597
+ if _answer_contains_markdown_table_row(answer, display_count):
598
+ return answer
599
+ return render_result(question, raw_result, "markdown")
600
+ if _answer_contains_display_row(answer, display_count):
601
+ return answer
602
+ local_format = result_format or "markdown"
603
+ return render_result(question, raw_result, local_format)
604
+
605
+
606
+ def _answer_contains_markdown_table_row(answer: str, row_number: int) -> bool:
607
+ escaped = re.escape(str(row_number))
608
+ return bool(re.search(rf"(?m)^\|\s*{escaped}\s*\|", answer))
609
+
610
+
611
+ def _answer_contains_display_row(answer: str, row_number: int) -> bool:
612
+ escaped = re.escape(str(row_number))
613
+ patterns = (
614
+ rf"(?m)^\|\s*{escaped}\s*\|",
615
+ rf"(?m)^\s*{escaped}[\.\)、]\s+",
616
+ rf"<td>\s*{escaped}\s*</td>",
617
+ rf'"序号"\s*:\s*"?{escaped}"?',
618
+ )
619
+ return any(re.search(pattern, answer) for pattern in patterns)
620
+
621
+
642
622
  def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
643
623
  result = {
644
624
  "content": [
@@ -686,11 +666,22 @@ def _error_response(request_id: Any, code: int, message: str, data: Json | None
686
666
  return {"jsonrpc": "2.0", "id": request_id, "error": error}
687
667
 
688
668
 
669
+ def _sampling_user_prompt(system_prompt: str, prompt: str) -> str:
670
+ system = (system_prompt or "").strip()
671
+ user = (prompt or "").strip()
672
+ if not system:
673
+ return user
674
+ if not user:
675
+ return system
676
+ return f"{system}\n\n---\n\n{user}"
677
+
678
+
689
679
  class StdioSession:
690
- def __init__(self, input_stream: Any, output_stream: Any) -> None:
680
+ def __init__(self, input_stream: Any, output_stream: Any, *, max_tokens: int = 4096) -> None:
691
681
  self.input_stream = input_stream
692
682
  self.output_stream = output_stream
693
683
  self.next_request_id = 1
684
+ self.max_tokens = max_tokens
694
685
 
695
686
  def read_message(self) -> Json | None:
696
687
  return _read_message(self.input_stream)
@@ -701,6 +692,9 @@ class StdioSession:
701
692
  def sample(self, system_prompt: str, prompt: str) -> str:
702
693
  request_id = f"sampling_{self.next_request_id}"
703
694
  self.next_request_id += 1
695
+ # roll sampling handler currently forwards only `messages` to the LLM and
696
+ # ignores `systemPrompt`; embed system instructions in the user payload.
697
+ user_prompt = _sampling_user_prompt(system_prompt, prompt)
704
698
  self.write_message(
705
699
  {
706
700
  "jsonrpc": "2.0",
@@ -708,9 +702,9 @@ class StdioSession:
708
702
  "method": "sampling/createMessage",
709
703
  "params": {
710
704
  "systemPrompt": system_prompt,
711
- "messages": [{"role": "user", "content": {"type": "text", "text": prompt}}],
705
+ "messages": [{"role": "user", "content": {"type": "text", "text": user_prompt}}],
712
706
  "temperature": 0,
713
- "maxTokens": 1600,
707
+ "maxTokens": self.max_tokens,
714
708
  "includeContext": "none",
715
709
  },
716
710
  }