@roll-agent/octopus-agent 0.1.3 → 0.2.0

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.
Files changed (36) hide show
  1. package/README.md +26 -190
  2. package/SKILL.md +27 -103
  3. package/package.json +12 -18
  4. package/references/env.yaml +1 -22
  5. package/scripts/start-octopus-agent.js +1 -125
  6. package/src/config.js +13 -0
  7. package/src/context.js +5 -0
  8. package/src/mcp-client.js +80 -0
  9. package/src/orchestrator.js +57 -0
  10. package/src/server-core.js +67 -0
  11. package/src/server.js +10 -0
  12. package/src/stdio.js +29 -0
  13. package/octopus_skill/__init__.py +0 -11
  14. package/pyproject.toml +0 -19
  15. package/scripts/refresh_dictionary.py +0 -67
  16. package/scripts/verify_binding.py +0 -47
  17. package/src/octopus_skill/__init__.py +0 -5
  18. package/src/octopus_skill/__main__.py +0 -4
  19. package/src/octopus_skill/_version.py +0 -3
  20. package/src/octopus_skill/agent.py +0 -5739
  21. package/src/octopus_skill/audit_logger.py +0 -29
  22. package/src/octopus_skill/config.py +0 -129
  23. package/src/octopus_skill/context.py +0 -17
  24. package/src/octopus_skill/device_info.py +0 -7
  25. package/src/octopus_skill/exporter.py +0 -66
  26. package/src/octopus_skill/llm_result_renderer.py +0 -101
  27. package/src/octopus_skill/llm_sql_generator.py +0 -197
  28. package/src/octopus_skill/main.py +0 -26
  29. package/src/octopus_skill/mcp_client.py +0 -114
  30. package/src/octopus_skill/octopus_run.py +0 -755
  31. package/src/octopus_skill/prompt_builder.py +0 -522
  32. package/src/octopus_skill/question_analyzer.py +0 -401
  33. package/src/octopus_skill/result_renderer.py +0 -367
  34. package/src/octopus_skill/schema_cache.py +0 -49
  35. package/src/octopus_skill/schema_context_retriever.py +0 -1053
  36. package/src/octopus_skill/sql_generator.py +0 -2866
@@ -1,755 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import re
5
- import sys
6
- from dataclasses import asdict, is_dataclass
7
- from typing import Any, Callable
8
-
9
- from . import __version__
10
- from .agent import SpongeAgent, ensure_query_plan_summary_answer
11
- from .config import AppConfig, load_config_from_env
12
- from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
13
- from .schema_cache import SchemaCacheError
14
-
15
- SERVER_NAME = "octopus-agent"
16
- SERVER_VERSION = __version__
17
- PROTOCOL_VERSION = "2024-11-05"
18
-
19
- Json = dict[str, Any]
20
- AgentFactory = Callable[[], SpongeAgent]
21
- SamplerFactory = Callable[[str, str], str]
22
-
23
-
24
- def main() -> None:
25
- serve_stdio()
26
-
27
-
28
- def serve_stdio() -> None:
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
- while True:
36
- message = session.read_message()
37
- if message is None:
38
- return
39
- response = handle_request(message, sampler=session.sample)
40
- if response is not None:
41
- session.write_message(response)
42
-
43
-
44
- def handle_request(
45
- message: Json,
46
- agent_factory: AgentFactory | None = None,
47
- sampler: SamplerFactory | None = None,
48
- ) -> Json | None:
49
- method = message.get("method")
50
- if method is None:
51
- return _error_response(message.get("id"), -32600, "Invalid request")
52
- if "id" not in message:
53
- return None
54
-
55
- request_id = message["id"]
56
- try:
57
- if method == "initialize":
58
- return _result_response(request_id, initialize_result())
59
- if method == "ping":
60
- return _result_response(request_id, {})
61
- if method == "tools/list":
62
- return _result_response(request_id, {"tools": tool_definitions()})
63
- if method == "tools/call":
64
- params = message.get("params") or {}
65
- return _result_response(request_id, call_tool(params, agent_factory=agent_factory, sampler=sampler))
66
- return _error_response(request_id, -32601, f"Method not found: {method}")
67
- except Exception as exc:
68
- return _error_response(request_id, -32603, str(exc), {"type": type(exc).__name__})
69
-
70
-
71
- def initialize_result() -> Json:
72
- return {
73
- "protocolVersion": PROTOCOL_VERSION,
74
- "capabilities": {"tools": {}},
75
- "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
76
- }
77
-
78
-
79
- def tool_definitions() -> list[Json]:
80
- return [
81
- {
82
- "name": "diagnostic_status",
83
- "description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,只调用本工具并在返回状态后停止,不得主动调用 query_sponge 或编造示例业务问题。普通业务查询不要先调用本工具,直接调用 query_sponge。",
84
- "inputSchema": {
85
- "type": "object",
86
- "properties": {},
87
- "additionalProperties": False,
88
- },
89
- },
90
- {
91
- "name": "refresh_schema",
92
- "description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema 时才调用;普通业务查询不要先调用本工具;query_sponge 内部会先检查 schemaVersion 并按需刷新 schema cache,query_sponge 失败后也不要自动调用本工具。",
93
- "inputSchema": {
94
- "type": "object",
95
- "required": ["traceId"],
96
- "properties": {
97
- "traceId": {
98
- "type": "string",
99
- "description": "调用链路 ID,必填,会原样传给 Sponge get_schema",
100
- }
101
- },
102
- "additionalProperties": False,
103
- },
104
- },
105
- {
106
- "name": "query_sponge",
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 会返回每一步状态和 durationMs,structuredContent.timing 会返回 totalDurationMs 与 stepDurationMs,失败时包含实际 error 原因。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim;复杂查询会使用 schema context 和 LLM 生成。",
108
- "inputSchema": {
109
- "type": "object",
110
- "required": ["question", "originalQuestion"],
111
- "properties": {
112
- "question": {"type": "string", "description": "当前要处理的问题;兼容旧调用方"},
113
- "originalQuestion": {
114
- "type": "string",
115
- "description": "必填,用户原始话术。上层不得摘要、翻译、拆分或改写;即使 question 与原话一致也要原样传入。",
116
- },
117
- "fastMode": {
118
- "type": "boolean",
119
- "description": "是否启用快路径;默认 true。true 时跳过 LLM SQL 专家复核,仍保留本地规则、validate_sql 与 execute_sql 兜底;false 时仅对含聚合/子查询的高风险 SQL 启用专家复核。",
120
- },
121
- "finalAnswerOnly": {
122
- "type": "boolean",
123
- "description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为三段式 verbatim 答复:1.查询实体;2.筛选条件;3.查询结果。上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要、截断表格或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
124
- },
125
- "sessionId": {"type": "string", "description": "可选会话 ID"},
126
- "resultFormat": {
127
- "type": "string",
128
- "enum": ["text", "json", "yaml", "md", "markdown", "html"],
129
- "description": "结果输出格式;不传则根据问题自动识别",
130
- },
131
- "includeSql": {
132
- "type": "boolean",
133
- "description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
134
- },
135
- },
136
- "additionalProperties": False,
137
- },
138
- },
139
- ]
140
-
141
-
142
- def call_tool(
143
- params: Json,
144
- agent_factory: AgentFactory | None = None,
145
- sampler: SamplerFactory | None = None,
146
- ) -> Json:
147
- tool_name = params.get("name")
148
- arguments = params.get("arguments") or {}
149
- try:
150
- if tool_name == "diagnostic_status":
151
- return _tool_json(_diagnostic_status(_load_config()))
152
- if tool_name == "refresh_schema":
153
- agent = _make_agent(agent_factory)
154
- return _tool_json(
155
- _refresh_schema(
156
- agent,
157
- trace_id=_required_string(arguments, "traceId"),
158
- )
159
- )
160
- if tool_name == "query_sponge":
161
- question = _required_string(arguments, "question")
162
- original_question = _required_string(arguments, "originalQuestion")
163
- session_id = arguments.get("sessionId")
164
- blocked_reason = _blocked_query_call_reason(
165
- question=question,
166
- original_question=original_question,
167
- )
168
- if blocked_reason is not None:
169
- return _tool_blocked_query(blocked_reason)
170
- agent = _make_agent(agent_factory)
171
- answer_kwargs = {
172
- "question": question,
173
- "original_question": original_question,
174
- "fast_mode": _optional_bool(arguments, "fastMode", default=True),
175
- "session_id": session_id,
176
- "result_format": arguments.get("resultFormat"),
177
- }
178
- final_answer_only = _optional_bool(arguments, "finalAnswerOnly", default=True)
179
- if sampler is not None:
180
- answer_kwargs["sampler"] = sampler
181
- query_result = agent.query(**answer_kwargs)
182
- base_question = original_question or question
183
- if arguments.get("includeSql") is True:
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)
186
- return _tool_json(payload, structured_content=payload)
187
- if final_answer_only:
188
- return _tool_final_answer(query_result, base_question=base_question)
189
- return _tool_query_answer(query_result, final_answer_only=False, base_question=base_question)
190
- return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
191
- except Exception as exc:
192
- return _tool_error(str(exc), code=type(exc).__name__)
193
-
194
-
195
- def _diagnostic_status(config: AppConfig) -> Json:
196
- cache_exists = config.schema.cache_path.exists()
197
- schema_version: str | None = None
198
- cache_error: str | None = None
199
- try:
200
- schema_version = SpongeAgent.from_config(config).schema_cache.version()
201
- except SchemaCacheError as exc:
202
- cache_error = str(exc)
203
-
204
- return {
205
- "server": {"name": SERVER_NAME, "version": SERVER_VERSION},
206
- "spongeMcp": {
207
- "baseUrl": config.sponge_mcp.base_url,
208
- "accessTokenConfigured": bool(config.sponge_mcp.access_token),
209
- "timeoutMs": config.sponge_mcp.timeout_ms,
210
- },
211
- "schema": {
212
- "cachePath": str(config.schema.cache_path),
213
- "cacheExists": cache_exists,
214
- "schemaVersion": schema_version,
215
- "cacheError": cache_error,
216
- },
217
- "audit": {
218
- "enabled": config.audit.enabled,
219
- "logPath": str(config.audit.log_path),
220
- },
221
- "sampling": {
222
- "maxTokens": config.sampling.max_tokens,
223
- },
224
- "binding": _binding_status(config),
225
- }
226
-
227
-
228
- def _binding_status(config: AppConfig) -> Json:
229
- binding = config.binding
230
- return {
231
- "configured": False,
232
- "mode": "disabled",
233
- "entitySource": "schema",
234
- "dictionaryPersisted": False,
235
- "shadowMode": bool(binding.shadow_mode) if binding is not None else None,
236
- "enabledEntityTypes": list(binding.enabled_entity_types) if binding is not None else [],
237
- "minFuzzyScore": binding.min_fuzzy_score if binding is not None else None,
238
- }
239
-
240
-
241
- def _refresh_schema(agent: SpongeAgent, *, trace_id: str) -> Json:
242
- cached_schema = agent.schema_cache.load()
243
- current_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
244
- schema = agent.mcp_client.get_schema(
245
- trace_id=trace_id,
246
- schema_version=current_version,
247
- )
248
- response_changed = schema.get("changed")
249
- if schema.get("changed") is False and cached_schema is not None:
250
- schema = cached_schema
251
- if "tables" in schema:
252
- agent.schema_cache.save(schema)
253
- schema_source = "cache" if response_changed is False and cached_schema is not None else "response"
254
- agent.audit_logger.log(
255
- {
256
- "traceId": trace_id,
257
- "tool": "get_schema",
258
- "schemaVersion": schema.get("schemaVersion"),
259
- "changed": response_changed,
260
- }
261
- )
262
- return {
263
- "changed": response_changed,
264
- "request": {
265
- "traceId": trace_id,
266
- "schemaVersion": current_version,
267
- },
268
- "schema": {
269
- "schemaVersion": schema.get("schemaVersion"),
270
- "generatedAt": schema.get("generatedAt"),
271
- "source": schema_source,
272
- },
273
- "cachePath": str(agent.schema_cache.path),
274
- }
275
-
276
-
277
- def _load_config() -> AppConfig:
278
- return load_config_from_env()
279
-
280
-
281
- def _make_agent(agent_factory: AgentFactory | None) -> SpongeAgent:
282
- if agent_factory is not None:
283
- return agent_factory()
284
- return SpongeAgent.from_config(_load_config())
285
-
286
-
287
- def _required_string(arguments: Json, key: str) -> str:
288
- value = arguments.get(key)
289
- if not isinstance(value, str) or not value:
290
- raise ValueError(f"{key} is required")
291
- return value
292
-
293
-
294
- def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
295
- value = arguments.get(key)
296
- if value is None:
297
- return default
298
- if not isinstance(value, bool):
299
- raise ValueError(f"{key} must be a boolean")
300
- return value
301
-
302
-
303
- def _required_int(arguments: Json, key: str) -> int:
304
- value = arguments.get(key)
305
- if not isinstance(value, int):
306
- raise ValueError(f"{key} is required")
307
- return value
308
-
309
-
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)
357
-
358
-
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:
363
- return None
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 必须传用户原始话术,上层不得摘要、翻译或改写。"
376
- )
377
- return None
378
-
379
-
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
390
-
391
-
392
- def _is_agent_meta_request(text: str) -> bool:
393
- return bool(
394
- re.search(r"(丸子|octopus|agent|roll[- ]?core|工具|技能)", text, flags=re.IGNORECASE)
395
- and re.search(r"(使用|启动|检查|看看|查看|测试|尝试|状态|健康|诊断|可用|功能)", text)
396
- )
397
-
398
-
399
- def _has_explicit_business_object(text: str) -> bool:
400
- return bool(
401
- re.search(
402
- r"(品牌|项目|岗位|门店|工单|报名|入职|候选人|城市|省份|区域|供应商|员工|薪资|招聘|数据|列表|明细|数量|多少|哪些|有哪些)",
403
- text,
404
- )
405
- )
406
-
407
-
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
- }
423
-
424
-
425
- def _tool_text(text: str) -> Json:
426
- return {"content": [{"type": "text", "text": text}]}
427
-
428
-
429
- def _answer_content(answer: str) -> list[Json]:
430
- return [
431
- {"type": "text", "text": answer},
432
- ]
433
-
434
-
435
- def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -> Json:
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
439
- payload.update(
440
- {
441
- "finalAnswerOnly": True,
442
- "responseMode": "verbatim",
443
- }
444
- )
445
- return {
446
- "content": _answer_content(answer),
447
- "structuredContent": payload,
448
- }
449
-
450
-
451
- def _tool_query_answer(query_result: Any, *, final_answer_only: bool, base_question: str | None = None) -> Json:
452
- payload = _query_result_payload(query_result, base_question=base_question)
453
- payload["finalAnswerOnly"] = final_answer_only
454
- payload["responseMode"] = "verbatim"
455
- answer = _user_visible_answer(query_result, base_question=base_question)
456
- payload["answer"] = answer
457
- return {
458
- "content": _answer_content(answer),
459
- "structuredContent": payload,
460
- }
461
-
462
-
463
- def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_question: str | None = None) -> Json:
464
- payload: Json = {
465
- "answer": str(getattr(query_result, "answer", "")),
466
- "needsClarification": bool(getattr(query_result, "needs_clarification", False)),
467
- "clarificationType": getattr(query_result, "clarification_type", None),
468
- "clarificationPayload": getattr(query_result, "clarification_payload", None),
469
- }
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
476
- timing = getattr(query_result, "timing", None)
477
- if isinstance(timing, dict) and timing:
478
- payload["timing"] = timing
479
- if include_sql:
480
- payload.update(
481
- {
482
- "generatedSql": getattr(query_result, "generated_sql", ""),
483
- "normalizedSql": getattr(query_result, "normalized_sql", ""),
484
- "repairedSql": getattr(query_result, "repaired_sql", None),
485
- "validation": getattr(query_result, "validation", {}),
486
- }
487
- )
488
- return _jsonable(payload)
489
-
490
-
491
- def _public_query_plan(query_plan: dict[str, Any]) -> Json:
492
- return {
493
- key: value
494
- for key, value in query_plan.items()
495
- if not str(key).startswith("_")
496
- }
497
-
498
-
499
- def _user_visible_answer(query_result: Any, *, base_question: str | None = None) -> str:
500
- answer = str(getattr(query_result, "answer", ""))
501
- question = base_question or ""
502
- result_format = getattr(query_result, "result_format", None)
503
- answer = _ensure_complete_detail_answer(query_result, answer, question, result_format)
504
- query_plan = getattr(query_result, "query_plan", None)
505
- return _ensure_two_section_answer(answer, question, result_format, query_plan)
506
-
507
-
508
- def _ensure_two_section_answer(
509
- answer: str,
510
- question: str,
511
- result_format: str | None,
512
- query_plan: Any,
513
- ) -> str:
514
- if isinstance(query_plan, dict) and query_plan:
515
- return ensure_query_plan_summary_answer(answer, question, result_format, query_plan)
516
- if _answer_has_two_section_structure(answer, question, result_format):
517
- return answer
518
- return _prepend_missing_query_plan_section(answer, question, result_format)
519
-
520
-
521
- def _answer_has_two_section_structure(answer: str, question: str, result_format: str | None) -> bool:
522
- output_format = normalize_result_format(question, result_format)
523
- if output_format == "json":
524
- try:
525
- payload = json.loads(answer)
526
- except json.JSONDecodeError:
527
- return False
528
- return (
529
- isinstance(payload, dict)
530
- and "查询实体" in payload
531
- and "筛选条件" in payload
532
- and "查询结果" in payload
533
- )
534
- if output_format == "yaml":
535
- return "查询实体:" in answer and "筛选条件:" in answer and "查询结果:" in answer
536
- return "1. 查询实体" in answer and "2. 筛选条件" in answer and "3. 查询结果" in answer
537
-
538
-
539
- def _prepend_missing_query_plan_section(answer: str, question: str, result_format: str | None) -> str:
540
- output_format = normalize_result_format(question, result_format)
541
- if output_format == "json":
542
- try:
543
- result_payload: Any = json.loads(answer)
544
- except json.JSONDecodeError:
545
- result_payload = answer
546
- return json.dumps(
547
- {
548
- "查询实体": ["未返回查询计划"],
549
- "筛选条件": ["未返回查询计划"],
550
- "查询结果": result_payload,
551
- },
552
- ensure_ascii=False,
553
- indent=2,
554
- ) + "\n"
555
- if output_format == "yaml":
556
- return (
557
- "查询实体:\n"
558
- " - 未返回查询计划\n"
559
- "筛选条件:\n"
560
- " - 未返回查询计划\n"
561
- "查询结果:\n"
562
- + "\n".join(f" {line}" if line else "" for line in answer.rstrip().splitlines())
563
- + "\n"
564
- )
565
- if output_format == "html":
566
- return (
567
- "<section><p>1. 查询实体:</p><p>未返回查询计划</p></section>"
568
- "<section><p>2. 筛选条件:</p><p>未返回查询计划</p></section>"
569
- "<section><p>3. 查询结果:</p>"
570
- + answer
571
- + "</section>"
572
- )
573
- return (
574
- "1. 查询实体:\n"
575
- "未返回查询计划\n\n"
576
- "2. 筛选条件:\n"
577
- "未返回查询计划\n\n"
578
- "3. 查询结果:\n"
579
- f"{answer}"
580
- )
581
-
582
-
583
- def _ensure_complete_detail_answer(
584
- query_result: Any,
585
- answer: str,
586
- question: str,
587
- result_format: str | None,
588
- ) -> str:
589
- raw_result = getattr(query_result, "raw_result", None)
590
- if not isinstance(raw_result, dict):
591
- return answer
592
- rows = raw_result.get("rows")
593
- if not isinstance(rows, list):
594
- return answer
595
- display_count = min(len(rows), RESULT_ROW_DISPLAY_LIMIT)
596
- if display_count <= 0:
597
- return answer
598
- output_format = normalize_result_format(question, result_format)
599
- if output_format in {"text", "markdown"}:
600
- if _answer_contains_markdown_table_row(answer, display_count):
601
- return answer
602
- return render_result(question, raw_result, "markdown")
603
- if _answer_contains_display_row(answer, display_count):
604
- return answer
605
- local_format = result_format or "markdown"
606
- return render_result(question, raw_result, local_format)
607
-
608
-
609
- def _answer_contains_markdown_table_row(answer: str, row_number: int) -> bool:
610
- escaped = re.escape(str(row_number))
611
- return bool(re.search(rf"(?m)^\|\s*{escaped}\s*\|", answer))
612
-
613
-
614
- def _answer_contains_display_row(answer: str, row_number: int) -> bool:
615
- escaped = re.escape(str(row_number))
616
- patterns = (
617
- rf"(?m)^\|\s*{escaped}\s*\|",
618
- rf"(?m)^\s*{escaped}[\.\)、]\s+",
619
- rf"<td>\s*{escaped}\s*</td>",
620
- rf'"序号"\s*:\s*"?{escaped}"?',
621
- )
622
- return any(re.search(pattern, answer) for pattern in patterns)
623
-
624
-
625
- def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
626
- result = {
627
- "content": [
628
- {
629
- "type": "text",
630
- "text": json.dumps(_jsonable(data), ensure_ascii=False, sort_keys=True),
631
- }
632
- ]
633
- }
634
- if structured_content is not None:
635
- result["structuredContent"] = _jsonable(structured_content)
636
- return result
637
-
638
-
639
- def _tool_error(message: str, *, code: str) -> Json:
640
- return {
641
- "isError": True,
642
- "content": [
643
- {
644
- "type": "text",
645
- "text": json.dumps({"code": code, "message": message}, ensure_ascii=False, sort_keys=True),
646
- }
647
- ],
648
- }
649
-
650
-
651
- def _jsonable(value: Any) -> Any:
652
- if is_dataclass(value):
653
- return _jsonable(asdict(value))
654
- if isinstance(value, dict):
655
- return {str(key): _jsonable(item) for key, item in value.items()}
656
- if isinstance(value, list):
657
- return [_jsonable(item) for item in value]
658
- return value
659
-
660
-
661
- def _result_response(request_id: Any, result: Json) -> Json:
662
- return {"jsonrpc": "2.0", "id": request_id, "result": result}
663
-
664
-
665
- def _error_response(request_id: Any, code: int, message: str, data: Json | None = None) -> Json:
666
- error: Json = {"code": code, "message": message}
667
- if data is not None:
668
- error["data"] = data
669
- return {"jsonrpc": "2.0", "id": request_id, "error": error}
670
-
671
-
672
- def _sampling_user_prompt(system_prompt: str, prompt: str) -> str:
673
- system = (system_prompt or "").strip()
674
- user = (prompt or "").strip()
675
- if not system:
676
- return user
677
- if not user:
678
- return system
679
- return f"{system}\n\n---\n\n{user}"
680
-
681
-
682
- class StdioSession:
683
- def __init__(self, input_stream: Any, output_stream: Any, *, max_tokens: int = 4096) -> None:
684
- self.input_stream = input_stream
685
- self.output_stream = output_stream
686
- self.next_request_id = 1
687
- self.max_tokens = max_tokens
688
-
689
- def read_message(self) -> Json | None:
690
- return _read_message(self.input_stream)
691
-
692
- def write_message(self, message: Json) -> None:
693
- _write_message(self.output_stream, message)
694
-
695
- def sample(self, system_prompt: str, prompt: str) -> str:
696
- request_id = f"sampling_{self.next_request_id}"
697
- self.next_request_id += 1
698
- # roll sampling handler currently forwards only `messages` to the LLM and
699
- # ignores `systemPrompt`; embed system instructions in the user payload.
700
- user_prompt = _sampling_user_prompt(system_prompt, prompt)
701
- self.write_message(
702
- {
703
- "jsonrpc": "2.0",
704
- "id": request_id,
705
- "method": "sampling/createMessage",
706
- "params": {
707
- "systemPrompt": system_prompt,
708
- "messages": [{"role": "user", "content": {"type": "text", "text": user_prompt}}],
709
- "temperature": 0,
710
- "maxTokens": self.max_tokens,
711
- "includeContext": "none",
712
- },
713
- }
714
- )
715
- while True:
716
- message = self.read_message()
717
- if message is None:
718
- raise RuntimeError("Sampling failed: client closed stdio")
719
- if message.get("id") == request_id:
720
- if "error" in message:
721
- raise RuntimeError(f"Sampling failed: {message['error']}")
722
- return _extract_sampling_text(message.get("result") or {})
723
- response = handle_request(message, sampler=self.sample)
724
- if response is not None:
725
- self.write_message(response)
726
-
727
-
728
- def _extract_sampling_text(result: Json) -> str:
729
- content = result.get("content")
730
- if isinstance(content, dict) and content.get("type") == "text":
731
- return str(content.get("text") or "")
732
- if isinstance(content, list):
733
- return "\n".join(
734
- str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"
735
- )
736
- raise RuntimeError("Sampling response did not contain text content")
737
-
738
-
739
- def _read_message(stream: Any) -> Json | None:
740
- while True:
741
- line = stream.readline()
742
- if line == b"":
743
- return None
744
- if line.strip():
745
- return json.loads(line.decode("utf-8"))
746
-
747
-
748
- def _write_message(stream: Any, message: Json) -> None:
749
- body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
750
- stream.write(body + b"\n")
751
- stream.flush()
752
-
753
-
754
- if __name__ == "__main__":
755
- main()