@roll-agent/octopus-agent 0.0.10 → 0.1.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.
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  import sys
5
6
  from dataclasses import asdict, is_dataclass
6
7
  from typing import Any, Callable
7
8
 
8
9
  from . import __version__
9
- from .agent import SpongeAgent
10
+ from .agent import SpongeAgent, ensure_query_plan_summary_answer
10
11
  from .config import AppConfig, load_config_from_env
12
+ from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
11
13
  from .schema_cache import SchemaCacheError
12
14
 
13
15
  SERVER_NAME = "octopus-agent"
@@ -24,7 +26,12 @@ def main() -> None:
24
26
 
25
27
 
26
28
  def serve_stdio() -> None:
27
- 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
+ )
28
35
  while True:
29
36
  message = session.read_message()
30
37
  if message is None:
@@ -73,7 +80,7 @@ def tool_definitions() -> list[Json]:
73
80
  return [
74
81
  {
75
82
  "name": "diagnostic_status",
76
- "description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
83
+ "description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,只调用本工具并在返回状态后停止,不得主动调用 query_sponge 或编造示例业务问题。普通业务查询不要先调用本工具,直接调用 query_sponge。",
77
84
  "inputSchema": {
78
85
  "type": "object",
79
86
  "properties": {},
@@ -97,7 +104,7 @@ def tool_definitions() -> list[Json]:
97
104
  },
98
105
  {
99
106
  "name": "query_sponge",
100
- "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 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后应把 answer 原文复制到最终答复正文完整展示,不要只放在折叠步骤、思考过程或工具调用详情中;如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。默认 fastMode=true、finalAnswerOnly=true,会优先使用规则模板,复杂查询会使用 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 生成。",
101
108
  "inputSchema": {
102
109
  "type": "object",
103
110
  "required": ["question", "originalQuestion"],
@@ -109,11 +116,11 @@ def tool_definitions() -> list[Json]:
109
116
  },
110
117
  "fastMode": {
111
118
  "type": "boolean",
112
- "description": "是否启用快路径;默认 true。true 时优先使用本地 schema 缓存、SQL 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL。",
119
+ "description": "是否启用快路径;默认 true。true 时优先使用本地 schema 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL。",
113
120
  },
114
121
  "finalAnswerOnly": {
115
122
  "type": "boolean",
116
- "description": "是否只返回最终业务答案;默认 true。普通上层 Agent 应保持 true,并在最终回复正文原文复制 answer,不要只放在折叠步骤里,不要追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
123
+ "description": "是否只返回最终业务答案;默认 true。true content structuredContent.answer 均为三段式 verbatim 答复:1.查询实体;2.筛选条件;3.查询结果。上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要、截断表格或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
117
124
  },
118
125
  "sessionId": {"type": "string", "description": "可选会话 ID"},
119
126
  "resultFormat": {
@@ -151,29 +158,35 @@ def call_tool(
151
158
  )
152
159
  )
153
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)
154
170
  agent = _make_agent(agent_factory)
155
171
  answer_kwargs = {
156
- "question": _required_string(arguments, "question"),
157
- "original_question": _required_string(arguments, "originalQuestion"),
172
+ "question": question,
173
+ "original_question": original_question,
158
174
  "fast_mode": _optional_bool(arguments, "fastMode", default=True),
159
- "session_id": arguments.get("sessionId"),
175
+ "session_id": session_id,
160
176
  "result_format": arguments.get("resultFormat"),
161
177
  }
162
178
  final_answer_only = _optional_bool(arguments, "finalAnswerOnly", default=True)
163
179
  if sampler is not None:
164
180
  answer_kwargs["sampler"] = sampler
165
181
  query_result = agent.query(**answer_kwargs)
182
+ base_question = original_question or question
166
183
  if arguments.get("includeSql") is True:
167
- return _tool_json(
168
- {
169
- "answer": query_result.answer,
170
- "generatedSql": query_result.generated_sql,
171
- "normalizedSql": query_result.normalized_sql,
172
- "repairedSql": query_result.repaired_sql,
173
- "validation": query_result.validation,
174
- }
175
- )
176
- return _tool_text(query_result.answer)
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)
177
190
  return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
178
191
  except Exception as exc:
179
192
  return _tool_error(str(exc), code=type(exc).__name__)
@@ -205,6 +218,23 @@ def _diagnostic_status(config: AppConfig) -> Json:
205
218
  "enabled": config.audit.enabled,
206
219
  "logPath": str(config.audit.log_path),
207
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,
208
238
  }
209
239
 
210
240
 
@@ -261,15 +291,6 @@ def _required_string(arguments: Json, key: str) -> str:
261
291
  return value
262
292
 
263
293
 
264
- def _optional_string(arguments: Json, key: str) -> str | None:
265
- value = arguments.get(key)
266
- if value is None:
267
- return None
268
- if not isinstance(value, str):
269
- raise ValueError(f"{key} must be a string")
270
- return value or None
271
-
272
-
273
294
  def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
274
295
  value = arguments.get(key)
275
296
  if value is None:
@@ -286,12 +307,312 @@ def _required_int(arguments: Json, key: str) -> int:
286
307
  return value
287
308
 
288
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
+
289
425
  def _tool_text(text: str) -> Json:
290
426
  return {"content": [{"type": "text", "text": text}]}
291
427
 
292
428
 
293
- def _tool_json(data: Any) -> Json:
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
294
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"] = query_plan
473
+ execution_steps = getattr(query_result, "execution_steps", None)
474
+ if isinstance(execution_steps, list):
475
+ payload["executionSteps"] = execution_steps
476
+ if include_sql:
477
+ payload.update(
478
+ {
479
+ "generatedSql": getattr(query_result, "generated_sql", ""),
480
+ "normalizedSql": getattr(query_result, "normalized_sql", ""),
481
+ "repairedSql": getattr(query_result, "repaired_sql", None),
482
+ "validation": getattr(query_result, "validation", {}),
483
+ }
484
+ )
485
+ return _jsonable(payload)
486
+
487
+
488
+ def _user_visible_answer(query_result: Any, *, base_question: str | None = None) -> str:
489
+ answer = str(getattr(query_result, "answer", ""))
490
+ question = base_question or ""
491
+ result_format = getattr(query_result, "result_format", None)
492
+ answer = _ensure_complete_detail_answer(query_result, answer, question, result_format)
493
+ query_plan = getattr(query_result, "query_plan", None)
494
+ return _ensure_two_section_answer(answer, question, result_format, query_plan)
495
+
496
+
497
+ def _ensure_two_section_answer(
498
+ answer: str,
499
+ question: str,
500
+ result_format: str | None,
501
+ query_plan: Any,
502
+ ) -> str:
503
+ if isinstance(query_plan, dict) and query_plan:
504
+ return ensure_query_plan_summary_answer(answer, question, result_format, query_plan)
505
+ if _answer_has_two_section_structure(answer, question, result_format):
506
+ return answer
507
+ return _prepend_missing_query_plan_section(answer, question, result_format)
508
+
509
+
510
+ def _answer_has_two_section_structure(answer: str, question: str, result_format: str | None) -> bool:
511
+ output_format = normalize_result_format(question, result_format)
512
+ if output_format == "json":
513
+ try:
514
+ payload = json.loads(answer)
515
+ except json.JSONDecodeError:
516
+ return False
517
+ return (
518
+ isinstance(payload, dict)
519
+ and "查询实体" in payload
520
+ and "筛选条件" in payload
521
+ and "查询结果" in payload
522
+ )
523
+ if output_format == "yaml":
524
+ return "查询实体:" in answer and "筛选条件:" in answer and "查询结果:" in answer
525
+ return "1. 查询实体" in answer and "2. 筛选条件" in answer and "3. 查询结果" in answer
526
+
527
+
528
+ def _prepend_missing_query_plan_section(answer: str, question: str, result_format: str | None) -> str:
529
+ output_format = normalize_result_format(question, result_format)
530
+ if output_format == "json":
531
+ try:
532
+ result_payload: Any = json.loads(answer)
533
+ except json.JSONDecodeError:
534
+ result_payload = answer
535
+ return json.dumps(
536
+ {
537
+ "查询实体": ["未返回查询计划"],
538
+ "筛选条件": ["未返回查询计划"],
539
+ "查询结果": result_payload,
540
+ },
541
+ ensure_ascii=False,
542
+ indent=2,
543
+ ) + "\n"
544
+ if output_format == "yaml":
545
+ return (
546
+ "查询实体:\n"
547
+ " - 未返回查询计划\n"
548
+ "筛选条件:\n"
549
+ " - 未返回查询计划\n"
550
+ "查询结果:\n"
551
+ + "\n".join(f" {line}" if line else "" for line in answer.rstrip().splitlines())
552
+ + "\n"
553
+ )
554
+ if output_format == "html":
555
+ return (
556
+ "<section><p>1. 查询实体:</p><p>未返回查询计划</p></section>"
557
+ "<section><p>2. 筛选条件:</p><p>未返回查询计划</p></section>"
558
+ "<section><p>3. 查询结果:</p>"
559
+ + answer
560
+ + "</section>"
561
+ )
562
+ return (
563
+ "1. 查询实体:\n"
564
+ "未返回查询计划\n\n"
565
+ "2. 筛选条件:\n"
566
+ "未返回查询计划\n\n"
567
+ "3. 查询结果:\n"
568
+ f"{answer}"
569
+ )
570
+
571
+
572
+ def _ensure_complete_detail_answer(
573
+ query_result: Any,
574
+ answer: str,
575
+ question: str,
576
+ result_format: str | None,
577
+ ) -> str:
578
+ raw_result = getattr(query_result, "raw_result", None)
579
+ if not isinstance(raw_result, dict):
580
+ return answer
581
+ rows = raw_result.get("rows")
582
+ if not isinstance(rows, list):
583
+ return answer
584
+ display_count = min(len(rows), RESULT_ROW_DISPLAY_LIMIT)
585
+ if display_count <= 0:
586
+ return answer
587
+ output_format = normalize_result_format(question, result_format)
588
+ if output_format in {"text", "markdown"}:
589
+ if _answer_contains_markdown_table_row(answer, display_count):
590
+ return answer
591
+ return render_result(question, raw_result, "markdown")
592
+ if _answer_contains_display_row(answer, display_count):
593
+ return answer
594
+ local_format = result_format or "markdown"
595
+ return render_result(question, raw_result, local_format)
596
+
597
+
598
+ def _answer_contains_markdown_table_row(answer: str, row_number: int) -> bool:
599
+ escaped = re.escape(str(row_number))
600
+ return bool(re.search(rf"(?m)^\|\s*{escaped}\s*\|", answer))
601
+
602
+
603
+ def _answer_contains_display_row(answer: str, row_number: int) -> bool:
604
+ escaped = re.escape(str(row_number))
605
+ patterns = (
606
+ rf"(?m)^\|\s*{escaped}\s*\|",
607
+ rf"(?m)^\s*{escaped}[\.\)、]\s+",
608
+ rf"<td>\s*{escaped}\s*</td>",
609
+ rf'"序号"\s*:\s*"?{escaped}"?',
610
+ )
611
+ return any(re.search(pattern, answer) for pattern in patterns)
612
+
613
+
614
+ def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
615
+ result = {
295
616
  "content": [
296
617
  {
297
618
  "type": "text",
@@ -299,6 +620,9 @@ def _tool_json(data: Any) -> Json:
299
620
  }
300
621
  ]
301
622
  }
623
+ if structured_content is not None:
624
+ result["structuredContent"] = _jsonable(structured_content)
625
+ return result
302
626
 
303
627
 
304
628
  def _tool_error(message: str, *, code: str) -> Json:
@@ -334,11 +658,22 @@ def _error_response(request_id: Any, code: int, message: str, data: Json | None
334
658
  return {"jsonrpc": "2.0", "id": request_id, "error": error}
335
659
 
336
660
 
661
+ def _sampling_user_prompt(system_prompt: str, prompt: str) -> str:
662
+ system = (system_prompt or "").strip()
663
+ user = (prompt or "").strip()
664
+ if not system:
665
+ return user
666
+ if not user:
667
+ return system
668
+ return f"{system}\n\n---\n\n{user}"
669
+
670
+
337
671
  class StdioSession:
338
- def __init__(self, input_stream: Any, output_stream: Any) -> None:
672
+ def __init__(self, input_stream: Any, output_stream: Any, *, max_tokens: int = 4096) -> None:
339
673
  self.input_stream = input_stream
340
674
  self.output_stream = output_stream
341
675
  self.next_request_id = 1
676
+ self.max_tokens = max_tokens
342
677
 
343
678
  def read_message(self) -> Json | None:
344
679
  return _read_message(self.input_stream)
@@ -349,6 +684,9 @@ class StdioSession:
349
684
  def sample(self, system_prompt: str, prompt: str) -> str:
350
685
  request_id = f"sampling_{self.next_request_id}"
351
686
  self.next_request_id += 1
687
+ # roll sampling handler currently forwards only `messages` to the LLM and
688
+ # ignores `systemPrompt`; embed system instructions in the user payload.
689
+ user_prompt = _sampling_user_prompt(system_prompt, prompt)
352
690
  self.write_message(
353
691
  {
354
692
  "jsonrpc": "2.0",
@@ -356,9 +694,9 @@ class StdioSession:
356
694
  "method": "sampling/createMessage",
357
695
  "params": {
358
696
  "systemPrompt": system_prompt,
359
- "messages": [{"role": "user", "content": {"type": "text", "text": prompt}}],
697
+ "messages": [{"role": "user", "content": {"type": "text", "text": user_prompt}}],
360
698
  "temperature": 0,
361
- "maxTokens": 1600,
699
+ "maxTokens": self.max_tokens,
362
700
  "includeContext": "none",
363
701
  },
364
702
  }