Skip to content

Commit 4a6c210

Browse files
feat(core): GenAI for Beginners 课程借鉴 P2 九项落地
P1 (PR HKUDS#183) 之上的 P2 候选 9 项,全部纯机制、独立模块: - A6 工具调用轨迹 trace 链: core/observability/trace.py (TraceSpan/TraceChain, 推理片段+参数+结果可查询, JSONL) - A7 工具语义发现: core/agent_runtime/tools/semantic_hint.py (未命中工具名给语义候选, 接入 registry not-found) - C4 few-shot 工具说明: EditTool description 加输入到调用到输出示例 (lesson 04 show-and-tell) - D3 记忆来源元数据: memory_retrieval.compose_memory_injection 带 created_at 时间戳可溯源 (lesson 08/14) - E2 groundedness 抽查: core/loop/groundedness.py (答案句子 vs 证据 token 覆盖, 可选 LLM-as-judge) - E3 MCP 供应链审计: core/mcp/audit.py (server 声明清单 + 风险清单 + allowlist 状态) - E4 LLMOps 指标聚合: core/observability/llmops.py (Quality/Harm/Honesty/Cost/Latency 五维) - F1 SLM 路由: core/loop/slm_routing.py (按子任务类别 SLM/LLM, env DEEPCODE_SLM_MODEL) - A9 顺序链 builder: core/loop/sequential_builder.py (SequentialChain + 前序结果占位符传递) 新增 59 项测试 (9 个新测试文件); P1+P2 合计 110 测试全绿; 对 upstream 0 新失败 (基线 12 个 Windows 环境失败 pre-existing)。
1 parent ed69d81 commit 4a6c210

19 files changed

Lines changed: 1692 additions & 15 deletions

core/agent_runtime/tools/registry.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,20 @@ def prepare_call(
8282

8383
tool = self._tools.get(name)
8484
if not tool:
85-
return (
86-
None,
87-
params,
88-
(
89-
f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
90-
),
91-
)
85+
# P2-A7: semantic candidates for a hallucinated/misremembered name
86+
# (lesson 17 Taskweaver plugin discovery). Execution still requires
87+
# the exact registered name + permission engine — the hint only
88+
# helps the model recover.
89+
try:
90+
from core.agent_runtime.tools.semantic_hint import build_miss_message
91+
92+
message = build_miss_message(name, self.tool_names)
93+
except Exception: # noqa: BLE001 - hint must never break the call
94+
message = (
95+
f"Error: Tool '{name}' not found. "
96+
f"Available: {', '.join(self.tool_names)}"
97+
)
98+
return (None, params, message)
9299

93100
cast_params = tool.cast_params(params)
94101
errors = tool.validate_params(cast_params)
@@ -112,8 +119,8 @@ async def execute(self, name: str, params: dict[str, Any]) -> Any:
112119
if isinstance(result, str) and result.startswith("Error"):
113120
return result + _HINT
114121
return result
115-
except Exception as e:
116-
return f"Error executing {name}: {str(e)}" + _HINT
122+
except Exception as e: # noqa: BLE001 - tool failures are errors-as-data
123+
return f"Error executing {name}: {e!s}" + _HINT
117124

118125
@property
119126
def tool_names(self) -> list[str]:
@@ -150,7 +157,7 @@ async def aclose(self) -> None:
150157
for name, stack in list(self._owned_server_stacks.items()):
151158
try:
152159
await asyncio.wait_for(stack.aclose(), timeout=timeout_s)
153-
except asyncio.TimeoutError:
160+
except TimeoutError:
154161
errors.append(
155162
TimeoutError(
156163
f"MCP server '{name}' close timed out after {timeout_s:g}s"
@@ -162,7 +169,7 @@ async def aclose(self) -> None:
162169
self._owned_server_stacks.pop(name, None)
163170
try:
164171
await asyncio.wait_for(self._exit_stack.aclose(), timeout=timeout_s)
165-
except asyncio.TimeoutError:
172+
except TimeoutError:
166173
errors.append(
167174
TimeoutError(
168175
f"ToolRegistry exit stack close timed out after {timeout_s:g}s"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""P2-A7 (GenAI lesson 17): tool-name miss semantic candidates.
2+
3+
Taskweaver stores plugins as embeddings and lets the LLM *semantically
4+
search* for the right plugin when the tool count grows. DeepCode routes tools
5+
by exact name; when the model hallucinates or misremembers a name, the
6+
registry returns "not found". This module adds the cheap first step: given the
7+
missed name and the available tool names, suggest the closest candidates by
8+
token-overlap similarity (no LLM, no embeddings — pure static scoring).
9+
10+
Design guard (lesson 13): semantic discovery is only a *hint* fed back to the
11+
model as an error message; execution still requires the exact registered name
12+
plus the permission engine. It never widens the callable surface.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import re
18+
from collections.abc import Iterable
19+
from difflib import SequenceMatcher
20+
21+
_WORD = re.compile(r"[a-z0-9]+")
22+
23+
24+
def _tokens(name: str) -> set[str]:
25+
return set(_WORD.findall(str(name).lower()))
26+
27+
28+
def _name_similarity(a: str, b: str) -> float:
29+
"""Combined token-overlap + sequence similarity in [0, 1]."""
30+
ta, tb = _tokens(a), _tokens(b)
31+
if ta and tb:
32+
overlap = len(ta & tb) / max(len(ta | tb), 1)
33+
else:
34+
overlap = 0.0
35+
seq = SequenceMatcher(None, a.lower(), b.lower()).ratio()
36+
return max(overlap, seq * 0.8)
37+
38+
39+
def suggest_tools(
40+
missed_name: str,
41+
available: Iterable[str],
42+
*,
43+
top_k: int = 3,
44+
min_similarity: float = 0.35,
45+
) -> list[str]:
46+
"""Candidates for a missed tool name, best first (empty when none close).
47+
48+
``min_similarity`` guards against suggesting unrelated tools; below it the
49+
caller should just report "not found" without noise (lesson 17: don't
50+
widen the surface with guesses).
51+
"""
52+
scored = [
53+
(candidate, _name_similarity(missed_name, candidate))
54+
for candidate in available
55+
if candidate != missed_name
56+
]
57+
scored = [(name, score) for name, score in scored if score >= min_similarity]
58+
scored.sort(key=lambda pair: pair[1], reverse=True)
59+
return [name for name, _score in scored[:top_k]]
60+
61+
62+
def build_miss_message(
63+
missed_name: str,
64+
available: Iterable[str],
65+
*,
66+
top_k: int = 3,
67+
min_similarity: float = 0.35,
68+
) -> str:
69+
"""Error-message helper: "not found" + semantic candidates (if any)."""
70+
candidates = suggest_tools(
71+
missed_name, available, top_k=top_k, min_similarity=min_similarity
72+
)
73+
if not candidates:
74+
return f"Tool '{missed_name}' not found."
75+
return (
76+
f"Tool '{missed_name}' not found. Did you mean one of: "
77+
+ ", ".join(candidates)
78+
+ "?"
79+
)
80+
81+
82+
__all__ = ["build_miss_message", "suggest_tools"]

core/harness/tools/files.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,13 @@ def description(self) -> str:
229229
return (
230230
"Edit a file by replacing old_string with new_string. Matching is "
231231
"resilient to whitespace/indentation drift; provide enough context "
232-
f"for old_string to be unique, or set replace_all.{scope}"
232+
"for old_string to be unique, or set replace_all.\n"
233+
"Example: file src/a.py contains 'def old(x): return 1'; call "
234+
'edit(file_path="src/a.py", old_string="def old(x): return 1", '
235+
'new_string="def new(x): return 2") to replace it. '
236+
"Use replace_all=true when the same snippet appears multiple times "
237+
"and all occurrences should change."
238+
f"{scope}"
233239
)
234240

235241
async def execute(self, **kwargs: Any) -> Any:

core/loop/groundedness.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""P2-E2 (GenAI lessons 13/14): groundedness spot-check.
2+
3+
Lesson 13 lists *output validation* among the four security-testing methods;
4+
lesson 14's Honesty/groundedness metric asks "does the answer follow from the
5+
supplied evidence?". This module provides a pure-mechanism spot-check: split a
6+
final answer into sentences, and for each sentence that makes an evidential
7+
claim, verify it is *supported* by the retrieved/injected evidence text.
8+
9+
Scoring (no LLM): a sentence is ``supported`` when a substantial fraction of
10+
its content tokens appear in the evidence; ``unsupported`` when it claims
11+
specific facts absent from the evidence. Optionally a caller can supply an
12+
LLM-as-judge callable for paraphrase-tolerant judgement (``judge_fn``) — the
13+
module stays mechanism-only by default.
14+
15+
Deliberately a *spot-check*: run on a sample or on critical decisions, never
16+
on every turn (lesson 14: cost control).
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import re
22+
from collections.abc import Callable
23+
from dataclasses import dataclass, field
24+
25+
_STOPWORDS = frozenset(
26+
{
27+
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
28+
"to", "of", "in", "on", "for", "with", "at", "by", "from", "as",
29+
"that", "this", "it", "its", "we", "our", "you", "your", "i", "me",
30+
"my", "be", "been", "being", "have", "has", "had", "do", "does",
31+
"did", "will", "would", "can", "could", "should", "not", "no",
32+
"yes", "so", "if", "then", "than", "there", "here", "which", "who",
33+
"when", "where", "why", "how", "all", "any", "both", "each", "few",
34+
"more", "most", "other", "some", "such", "only", "own", "same",
35+
}
36+
)
37+
38+
_SENTENCE = re.compile(
39+
r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s+(?=[A-Z0-9])"
40+
)
41+
_WORD = re.compile(r"[a-z0-9']+")
42+
43+
_SUPPORT_THRESHOLD = 0.5 # fraction of content tokens present in evidence
44+
_MIN_SENTENCE_TOKENS = 2 # ignore fragments like "42" or "Done."
45+
46+
47+
def _content_tokens(text: str) -> set[str]:
48+
return {
49+
w
50+
for w in _WORD.findall(str(text).lower())
51+
if w not in _STOPWORDS and len(w) > 1
52+
}
53+
54+
55+
def _split_sentences(text: str) -> list[str]:
56+
"""Split on sentence boundaries, keeping 'src/parser.py.' intact.
57+
58+
Uses a lookbehind-boundary split (period/question/exclamation followed by
59+
whitespace + capital) instead of a naive character class, so dotted paths
60+
and abbreviations do not fragment into fake sentences.
61+
"""
62+
parts = re.split(_SENTENCE, str(text))
63+
return [p.strip() for p in parts if p.strip()]
64+
65+
66+
@dataclass
67+
class GroundednessVerdict:
68+
"""One sentence's support verdict."""
69+
70+
sentence: str
71+
supported: bool
72+
coverage: float
73+
reason: str = ""
74+
75+
76+
@dataclass
77+
class GroundednessReport:
78+
"""Aggregate spot-check over an answer against its evidence."""
79+
80+
answer: str
81+
evidence: str
82+
verdicts: list[GroundednessVerdict] = field(default_factory=list)
83+
84+
@property
85+
def supported_ratio(self) -> float:
86+
if not self.verdicts:
87+
return 0.0
88+
return sum(1 for v in self.verdicts if v.supported) / len(self.verdicts)
89+
90+
def unsupported_sentences(self) -> list[GroundednessVerdict]:
91+
return [v for v in self.verdicts if not v.supported]
92+
93+
94+
def check_groundedness(
95+
answer: str,
96+
evidence: str,
97+
*,
98+
threshold: float = _SUPPORT_THRESHOLD,
99+
judge_fn: Callable[[str, str], bool] | None = None,
100+
) -> GroundednessReport:
101+
"""Split ``answer`` into sentences and judge each against ``evidence``.
102+
103+
``judge_fn(sentence, evidence) -> bool`` lets a caller plug an
104+
LLM-as-judge for paraphrase-tolerant checks; when absent the default
105+
token-coverage heuristic runs (pure mechanism, zero cost).
106+
"""
107+
answer = str(answer or "")
108+
evidence = str(evidence or "")
109+
evidence_tokens = _content_tokens(evidence)
110+
report = GroundednessReport(answer=answer, evidence=evidence)
111+
112+
for sentence in _split_sentences(answer):
113+
if judge_fn is not None:
114+
try:
115+
supported = bool(judge_fn(sentence, evidence))
116+
except Exception: # noqa: BLE001 - judge failure is a soft miss
117+
supported = False
118+
report.verdicts.append(
119+
GroundednessVerdict(
120+
sentence=sentence,
121+
supported=supported,
122+
coverage=1.0 if supported else 0.0,
123+
reason="judge_fn" if supported else "judge_fn (failed or false)",
124+
)
125+
)
126+
continue
127+
tokens = _content_tokens(sentence)
128+
if len(tokens) < _MIN_SENTENCE_TOKENS:
129+
continue # non-evidential fragment (e.g. a bare number)
130+
present = sum(1 for t in tokens if t in evidence_tokens)
131+
coverage = present / len(tokens)
132+
supported = coverage >= threshold
133+
report.verdicts.append(
134+
GroundednessVerdict(
135+
sentence=sentence,
136+
supported=supported,
137+
coverage=round(coverage, 3),
138+
reason=(
139+
f"{present}/{len(tokens)} content tokens in evidence"
140+
if supported
141+
else (
142+
f"only {present}/{len(tokens)} content tokens in "
143+
"evidence; facts may be fabricated"
144+
)
145+
),
146+
)
147+
)
148+
return report
149+
150+
151+
__all__ = [
152+
"GroundednessReport",
153+
"GroundednessVerdict",
154+
"check_groundedness",
155+
]

core/loop/memory_retrieval.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ def compose_memory_injection(
7070
"""Render accepted entries as a numbered, data-bounded injection block.
7171
7272
Each entry becomes one ``<untrusted-data>`` block carrying the P1-3
73-
restrict clause (reference only, never instructions) plus its source
74-
metadata (``source_key`` / ``source`` when present) for traceability.
73+
restrict clause (reference only, never instructions) plus traceable
74+
metadata — source key, source layer, and creation timestamp when present
75+
(P2-D3, lessons 08/14: retrieved results carry locators so answers can be
76+
grounded and attributed). Entries are numbered ``[n]``; the model may cite
77+
``[n]`` to attribute a claim to a specific memory.
7578
Empty when nothing clears the threshold — the caller then uses
7679
:func:`no_memory_statement` instead of injecting weak matches.
7780
"""
@@ -82,7 +85,11 @@ def compose_memory_injection(
8285
if not content:
8386
continue
8487
source = entry.get("source_key") or entry.get("source") or "memory"
85-
header = f"[{index}] (from {source})"
88+
created_at = entry.get("created_at") or entry.get("timestamp")
89+
header = f"[{index}] (from {source}"
90+
if created_at:
91+
header += f", at {created_at}"
92+
header += ")"
8693
blocks.append(f"{header}\n{render_data_block(content)}")
8794
return "\n\n".join(blocks)
8895

0 commit comments

Comments
 (0)