Skip to content

Commit 4d8ba2b

Browse files
committed
feat: add reusable output templates with per-agent overrides
1 parent c68ec9a commit 4d8ba2b

File tree

6 files changed

+304
-5
lines changed

6 files changed

+304
-5
lines changed

agents/gqy20/agent.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ attempt_timeout_seconds: 300
2424
enable_skills: true
2525
enable_subagents: true
2626
enable_mcp: true
27+
output_format: markdown
28+
mentions_mode: controlled
29+
output_template: local:deep_research_v1
2730

2831
# 仓库配置(本地执行)
2932
repository: "gqy20/IssueLab"

agents/gqy20/output_config.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
default_template: deep_research_v1
2+
3+
templates:
4+
deep_research_v1:
5+
section_order:
6+
- summary
7+
- findings
8+
- evidence_gaps
9+
- actions
10+
- sources
11+
sections:
12+
summary:
13+
title: "## Summary"
14+
guidance: "1-3 句给出总体结论和判断边界。"
15+
findings:
16+
title: "## Key Findings"
17+
guidance: "列出 3-6 条关键发现,尽量附带证据线索。"
18+
evidence_gaps:
19+
title: "## Evidence Gaps"
20+
guidance: "明确缺失证据、待验证假设和风险。"
21+
actions:
22+
title: "## Recommended Actions"
23+
guidance: "给出 2-5 条可执行动作,按优先级排序。"
24+
sources:
25+
title: "## Sources"
26+
guidance: "附上可追溯 URL。"

agents/gqy22/agent.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ timeout_seconds: 180
2323
enable_skills: true
2424
enable_subagents: true
2525
enable_mcp: true
26+
output_format: markdown
27+
mentions_mode: controlled
28+
output_template: concise_review_v1
2629

2730
# 仓库配置(重要:你的 fork 仓库)
2831
repository: "gqy22/IssueLab"

config/output_templates.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
version: 1
2+
default_template: review_v1
3+
4+
templates:
5+
review_v1:
6+
section_order:
7+
- summary
8+
- findings
9+
- actions
10+
- risks
11+
- sources
12+
sections:
13+
summary:
14+
title: "## Summary"
15+
guidance: "1-3 句结论,说明结论范围与当前状态。"
16+
findings:
17+
title: "## Key Findings"
18+
guidance: "2-5 条要点,使用列表,尽量给事实与证据。"
19+
actions:
20+
title: "## Recommended Actions"
21+
guidance: "1-5 条可执行动作,明确优先级或先后顺序。"
22+
risks:
23+
title: "## Risks & Uncertainties"
24+
guidance: "列出风险、假设和不确定性。"
25+
sources:
26+
title: "## Sources"
27+
guidance: "提供可追溯 URL,避免无来源断言。"
28+
29+
concise_review_v1:
30+
section_order:
31+
- summary
32+
- actions
33+
- sources
34+
sections:
35+
summary:
36+
title: "## Summary"
37+
guidance: "1-2 句核心结论。"
38+
actions:
39+
title: "## Recommended Actions"
40+
guidance: "最多 3 条动作,优先高价值项。"
41+
sources:
42+
title: "## Sources"
43+
guidance: "列出关键来源 URL。"

src/issuelab/agents/executor.py

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import sys
10+
from pathlib import Path
1011
from typing import Any, cast
1112

1213
import anyio
@@ -34,6 +35,8 @@
3435

3536
_ALLOWED_OUTPUT_FORMATS = {"markdown", "yaml", "hybrid"}
3637
_ALLOWED_MENTIONS_MODES = {"controlled", "required", "off"}
38+
_GLOBAL_OUTPUT_TEMPLATES_CACHE: dict[str, Any] | None = None
39+
_AGENT_OUTPUT_CONFIG_CACHE: dict[str, dict[str, Any]] = {}
3740

3841
_OUTPUT_SCHEMA_BLOCK_MARKDOWN = (
3942
"\n\n## Output Format (required)\n"
@@ -98,16 +101,157 @@ def _normalize_mentions_mode(value: Any) -> str:
98101
return "controlled"
99102

100103

101-
def _get_output_preferences(agent_name: str) -> tuple[str, str]:
104+
def _get_project_root() -> Path:
105+
return Path.cwd()
106+
107+
108+
def _load_global_output_templates(root_dir: Path | None = None) -> dict[str, Any]:
109+
global _GLOBAL_OUTPUT_TEMPLATES_CACHE
110+
if _GLOBAL_OUTPUT_TEMPLATES_CACHE is not None:
111+
return _GLOBAL_OUTPUT_TEMPLATES_CACHE
112+
113+
root = root_dir or _get_project_root()
114+
path = root / "config" / "output_templates.yml"
115+
if not path.exists():
116+
_GLOBAL_OUTPUT_TEMPLATES_CACHE = {}
117+
return _GLOBAL_OUTPUT_TEMPLATES_CACHE
118+
try:
119+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
120+
_GLOBAL_OUTPUT_TEMPLATES_CACHE = data if isinstance(data, dict) else {}
121+
except Exception:
122+
_GLOBAL_OUTPUT_TEMPLATES_CACHE = {}
123+
return _GLOBAL_OUTPUT_TEMPLATES_CACHE
124+
125+
126+
def _load_agent_output_config(agent_name: str, root_dir: Path | None = None) -> dict[str, Any]:
127+
key = f"{root_dir or _get_project_root()}::{agent_name}"
128+
if key in _AGENT_OUTPUT_CONFIG_CACHE:
129+
return _AGENT_OUTPUT_CONFIG_CACHE[key]
130+
131+
root = root_dir or _get_project_root()
132+
path = root / "agents" / agent_name / "output_config.yml"
133+
if not path.exists():
134+
_AGENT_OUTPUT_CONFIG_CACHE[key] = {}
135+
return _AGENT_OUTPUT_CONFIG_CACHE[key]
136+
try:
137+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
138+
_AGENT_OUTPUT_CONFIG_CACHE[key] = data if isinstance(data, dict) else {}
139+
except Exception:
140+
_AGENT_OUTPUT_CONFIG_CACHE[key] = {}
141+
return _AGENT_OUTPUT_CONFIG_CACHE[key]
142+
143+
144+
def _resolve_output_template(
145+
agent_name: str, template_id: str | None, *, root_dir: Path | None = None, default_template: str = "review_v1"
146+
) -> dict[str, Any] | None:
147+
global_config = _load_global_output_templates(root_dir=root_dir)
148+
global_templates = global_config.get("templates", {}) if isinstance(global_config, dict) else {}
149+
agent_config = _load_agent_output_config(agent_name, root_dir=root_dir)
150+
local_templates = agent_config.get("templates", {}) if isinstance(agent_config, dict) else {}
151+
152+
template_name = template_id
153+
if not template_name:
154+
local_default = agent_config.get("default_template")
155+
global_default = global_config.get("default_template") if isinstance(global_config, dict) else None
156+
if isinstance(local_default, str) and local_default.strip():
157+
template_name = local_default.strip()
158+
elif isinstance(global_default, str) and global_default.strip():
159+
template_name = global_default.strip()
160+
else:
161+
template_name = default_template
162+
163+
if template_name.startswith("local:"):
164+
local_name = template_name.split(":", 1)[1].strip()
165+
candidate = local_templates.get(local_name)
166+
return candidate if isinstance(candidate, dict) else None
167+
168+
if (
169+
isinstance(local_templates, dict)
170+
and template_name in local_templates
171+
and isinstance(local_templates[template_name], dict)
172+
):
173+
return local_templates[template_name]
174+
if (
175+
isinstance(global_templates, dict)
176+
and template_name in global_templates
177+
and isinstance(global_templates[template_name], dict)
178+
):
179+
return global_templates[template_name]
180+
return None
181+
182+
183+
def _build_template_instruction(
184+
template: dict[str, Any], *, mentions_mode: str, output_format: str, section_order_override: list[str] | None = None
185+
) -> str | None:
186+
sections = template.get("sections")
187+
section_order = section_order_override or template.get("section_order")
188+
if not isinstance(sections, dict) or not isinstance(section_order, list):
189+
return None
190+
191+
ordered: list[str] = [str(item) for item in section_order if isinstance(item, str)]
192+
if not ordered:
193+
return None
194+
195+
lines = ["", "", "## Output Format (required)"]
196+
if output_format == "hybrid":
197+
lines.append("优先使用 Markdown;仅在无法稳定输出时允许单个 YAML 代码块。")
198+
else:
199+
lines.append("请使用 Markdown 输出,禁止输出 YAML/JSON 代码块。")
200+
lines.append("")
201+
lines.append("请按以下段落顺序输出:")
202+
203+
for index, section_key in enumerate(ordered, 1):
204+
config = sections.get(section_key)
205+
if not isinstance(config, dict):
206+
continue
207+
title = str(config.get("title") or f"## {section_key.replace('_', ' ').title()}").strip()
208+
guidance = str(config.get("guidance") or "").strip()
209+
if guidance:
210+
lines.append(f"{index}. `{title}`:{guidance}")
211+
else:
212+
lines.append(f"{index}. `{title}`")
213+
214+
mention_instruction = {
215+
"controlled": "如需触发协作,仅在文末使用受控区:`---\\n相关人员: @user1 @user2` 或 `协作请求:` 列表。",
216+
"required": "必须在文末使用受控区输出协作对象:`---\\n相关人员: @user1 @user2` 或 `协作请求:` 列表。",
217+
"off": "不要输出 `相关人员`/`协作请求` 受控区。",
218+
}.get(mentions_mode, "如需触发协作,仅在文末使用受控区:`---\\n相关人员: @user1 @user2` 或 `协作请求:` 列表。")
219+
lines.extend(["", f"- {mention_instruction}"])
220+
return "\n".join(lines)
221+
222+
223+
def _get_output_preferences(agent_name: str) -> tuple[str, str, str | None, list[str] | None]:
102224
try:
103225
config = get_agent_config(agent_name) or {}
104226
except Exception:
105227
config = {}
106-
return _normalize_output_format(config.get("output_format")), _normalize_mentions_mode(config.get("mentions_mode"))
228+
229+
template_id = config.get("output_template")
230+
if not isinstance(template_id, str):
231+
template_id = None
232+
233+
section_order = config.get("section_order")
234+
parsed_section_order: list[str] | None = None
235+
if isinstance(section_order, list) and all(isinstance(x, str) for x in section_order):
236+
parsed_section_order = [str(x) for x in section_order]
237+
238+
return (
239+
_normalize_output_format(config.get("output_format")),
240+
_normalize_mentions_mode(config.get("mentions_mode")),
241+
template_id,
242+
parsed_section_order,
243+
)
107244

108245

109246
def _append_output_schema(
110-
prompt: str, stage_name: str | None = None, *, output_format: str = "markdown", mentions_mode: str = "controlled"
247+
prompt: str,
248+
agent_name: str,
249+
stage_name: str | None = None,
250+
*,
251+
output_format: str = "markdown",
252+
mentions_mode: str = "controlled",
253+
output_template: str | None = None,
254+
section_order: list[str] | None = None,
111255
) -> str:
112256
"""为 prompt 注入统一输出格式(如果尚未注入)。"""
113257
if "## Output Format (required)" in prompt:
@@ -123,6 +267,15 @@ def _append_output_schema(
123267

124268
if output_format == "yaml":
125269
return f"{prompt}{_OUTPUT_SCHEMA_BLOCK_YAML}"
270+
271+
template = _resolve_output_template(agent_name, output_template)
272+
if template:
273+
rendered = _build_template_instruction(
274+
template, mentions_mode=mentions_mode, output_format=output_format, section_order_override=section_order
275+
)
276+
if rendered:
277+
return f"{prompt}{rendered}"
278+
126279
if output_format == "hybrid":
127280
return f"{prompt}{_OUTPUT_SCHEMA_BLOCK_HYBRID}{mention_instruction}"
128281
return f"{prompt}{_OUTPUT_SCHEMA_BLOCK_MARKDOWN}{mention_instruction}"
@@ -146,7 +299,7 @@ async def run_single_agent(prompt: str, agent_name: str, *, stage_name: str | No
146299
"""
147300
logger.info(f"[{agent_name}] 开始运行 Agent")
148301
logger.debug(f"[{agent_name}] Prompt 长度: {len(prompt)} 字符")
149-
output_format, mentions_mode = _get_output_preferences(agent_name)
302+
output_format, mentions_mode, output_template, section_order = _get_output_preferences(agent_name)
150303

151304
# 执行信息收集
152305
execution_info: dict[str, Any] = {
@@ -173,7 +326,13 @@ async def _query_agent():
173326
first_result = True
174327

175328
effective_prompt = _append_output_schema(
176-
prompt, stage_name=stage_name, output_format=output_format, mentions_mode=mentions_mode
329+
prompt,
330+
agent_name,
331+
stage_name=stage_name,
332+
output_format=output_format,
333+
mentions_mode=mentions_mode,
334+
output_template=output_template,
335+
section_order=section_order,
177336
)
178337
async for message in query(prompt=effective_prompt, options=options):
179338
# AssistantMessage: AI 响应(文本或工具调用)

tests/test_sdk_executor.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,71 @@ async def mock_query(*args, **kwargs):
723723

724724
assert "优先输出以下 YAML" in captured["prompt"]
725725

726+
@pytest.mark.asyncio
727+
async def test_output_schema_uses_global_template(self):
728+
"""当配置 output_template 时应按模板注入段落规范。"""
729+
from claude_agent_sdk import ResultMessage
730+
731+
from issuelab.agents.executor import run_single_agent
732+
733+
captured = {}
734+
735+
async def mock_query(*args, **kwargs):
736+
captured["prompt"] = kwargs.get("prompt")
737+
result = MagicMock(spec=ResultMessage)
738+
result.total_cost_usd = 0.0
739+
result.num_turns = 1
740+
result.session_id = "test-session"
741+
yield result
742+
743+
with (
744+
patch("issuelab.agents.executor.query", mock_query),
745+
patch(
746+
"issuelab.agents.executor.get_agent_config",
747+
return_value={"output_format": "markdown", "output_template": "concise_review_v1"},
748+
),
749+
):
750+
await run_single_agent("test prompt", "test_agent")
751+
752+
assert "## Output Format (required)" in captured["prompt"]
753+
assert "`## Summary`" in captured["prompt"]
754+
assert "`## Recommended Actions`" in captured["prompt"]
755+
assert "`## Sources`" in captured["prompt"]
756+
757+
@pytest.mark.asyncio
758+
async def test_output_schema_applies_section_order_override(self):
759+
"""agent.yml 的 section_order 应覆盖模板默认顺序。"""
760+
from claude_agent_sdk import ResultMessage
761+
762+
from issuelab.agents.executor import run_single_agent
763+
764+
captured = {}
765+
766+
async def mock_query(*args, **kwargs):
767+
captured["prompt"] = kwargs.get("prompt")
768+
result = MagicMock(spec=ResultMessage)
769+
result.total_cost_usd = 0.0
770+
result.num_turns = 1
771+
result.session_id = "test-session"
772+
yield result
773+
774+
with (
775+
patch("issuelab.agents.executor.query", mock_query),
776+
patch(
777+
"issuelab.agents.executor.get_agent_config",
778+
return_value={
779+
"output_format": "markdown",
780+
"output_template": "review_v1",
781+
"section_order": ["summary", "actions", "sources"],
782+
},
783+
),
784+
):
785+
await run_single_agent("test prompt", "test_agent")
786+
787+
assert "`## Summary`" in captured["prompt"]
788+
assert "`## Recommended Actions`" in captured["prompt"]
789+
assert "`## Sources`" in captured["prompt"]
790+
726791
@pytest.mark.asyncio
727792
async def test_system_agent_timeout_defaults_to_600(self):
728793
"""system agent 在无显式配置时应使用 600s 超时"""

0 commit comments

Comments
 (0)