Why: Real transcripts (dev instance, 62 assistant turns) showed the artifact gate silently off and self-deadlocking. lintFile ran python3 -m housestyle.lint without the skills PYTHONPATH, so the import failed and nil was treated as "no issues" (same docx: 9 lint errors with PYTHONPATH, 0 without). stop() re-scored every artifact registered in the run, including superseded versions, so a fixed document could never pass and the composed reply reported the old verdict. ctxasm rendered history artifacts as "[产出工件 …]" - the exact pattern ClaimGate treats as a forged tool marker, so models copying the format were rejected. scoreReadable flagged table cells like "项目概述撰写" as column headings. Prompts and recipes said "python -m" (no python on PATH; exit 127) and housestyle.summarize was invoked as a module that does not exist. Evidence: SELF - new tests TestLintUnavailableFailsVisibleWhenSkillsRootSet, TestLintCommandEnvCarriesSkillsRoot, TestDocxHeadingMarkedAndTableCellNotFlagged, TestLatestDeliverablesKeepsLastPerName, TestRecipesUseRunnablePythonCommands, TestLibraryUsesRunnablePythonCommands; go test ./internal/... passes; manual: python3 -m housestyle.outline_cli reads a real artifact docx cleanly, lint without PYTHONPATH exits 1 with ModuleNotFoundError and is now surfaced as 自检未运行 when the skills root is configured. Impact: compose root injects quality.SetLintEnvRoot(<data>/skills). Final-turn checks score only the newest artifact per name; deliverable lists and reply cards follow the same dedupe. History artifact placeholders no longer collide with the forged-marker regex. docx text extraction marks Title/HeadingN styles so readability only judges real headings, with line numbers in the reason. Check reminders no longer invite the model to ignore failed checks. Oversized tool output is truncated by runes, not bytes. Refs: Dev task feature/agent-quality-phase01 (quality gap diagnosis 2026-10-09).
139 lines
4.9 KiB
Go
139 lines
4.9 KiB
Go
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
agentllm "agent-native/runtime/llm"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// 工件编号和字节数只属于系统记录。收尾模型见过工具摘要后会把它们抄进回复。
|
|
var internalRef = regexp.MustCompile(`art_[0-9a-f]{8,}|\d[\d,]*\s*字节`)
|
|
|
|
const composeFallbackPrompt = `你负责写给用户的最终回复。用户只看这一段,看不到上面的工作过程。
|
|
|
|
规则:
|
|
- 只输出给用户看的一段话。不要写思考过程,不要使用 think 标签,不要写「根据规则」「所以我应该」。
|
|
- 直接回应用户的原始请求。先写结果和结论,再写交付了什么、要点是什么、还可以怎么调整。
|
|
- 不要复述过程,不要提内部检查、lint、重试、验证器、评分、文件路径、字节数或工件编号。
|
|
- 检查结论只用来判断有没有没做完的事。文件已经按用户的要求生成时,不要把评分或版式意见告诉用户。
|
|
- 只有用户的请求确实没完成时才说明,用用户能懂的话,不要引用检查原文。
|
|
- 不要编造材料里没有的内容。使用与用户相同的语言。`
|
|
|
|
func (l *LLMRunner) composePrompt(owner string) string {
|
|
if l.Prompts != nil {
|
|
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.compose.final"); err == nil && ok && strings.TrimSpace(body) != "" {
|
|
return body
|
|
}
|
|
}
|
|
return composeFallbackPrompt
|
|
}
|
|
|
|
// composeFinal 在工作循环结束后,用主模型单独写一段直接回答用户的回复。
|
|
// 失败或空结果时恢复草稿,不影响这一轮已经成功结束。
|
|
func (st *directRun) composeFinal(stream agentllm.StreamFunc, model, notice string, opts agentllm.StreamOptions) bool {
|
|
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseFinalizing})
|
|
draftIdx := st.demoteLastText()
|
|
draft := ""
|
|
if draftIdx >= 0 {
|
|
draft = st.finalParts[draftIdx].Text
|
|
}
|
|
msgs := []agentllm.Message{
|
|
{Role: agentllm.RoleSystem, Content: st.l.composePrompt(st.rc.Profile().Owner)},
|
|
{Role: agentllm.RoleUser, Content: st.composeUserPrompt(draft, notice)},
|
|
}
|
|
var b strings.Builder
|
|
failed := false
|
|
for ev := range stream(st.rc.Context(), model, msgs, nil, opts) {
|
|
switch ev.Kind {
|
|
case agentllm.KindTextDelta:
|
|
b.WriteString(ev.Text)
|
|
case agentllm.KindError:
|
|
failed = true
|
|
case agentllm.KindMessageEnd:
|
|
st.usageTotal.PromptTokens += ev.Usage.PromptTokens
|
|
st.usageTotal.CompletionTokens += ev.Usage.CompletionTokens
|
|
st.usageTotal.TotalTokens += ev.Usage.TotalTokens
|
|
if b.Len() == 0 {
|
|
b.WriteString(ev.Message.Content)
|
|
}
|
|
}
|
|
}
|
|
// 有的模型把推理放进 think 再写正文。推理不发给用户;只发出剥离后的正文。
|
|
body, _ := splitThinkBlock(b.String())
|
|
body = scrubInternal(body)
|
|
if failed || body == "" || strings.Contains(body, "<think>") {
|
|
if draftIdx >= 0 && draftIdx < len(st.finalParts) && st.finalParts[draftIdx].Type == core.PartText {
|
|
st.finalParts[draftIdx].Process = false
|
|
}
|
|
return false
|
|
}
|
|
_ = st.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: st.textIdx, Text: body})
|
|
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body})
|
|
st.textIdx++
|
|
return true
|
|
}
|
|
|
|
func (st *directRun) composeUserPrompt(draft, notice string) string {
|
|
var b strings.Builder
|
|
b.WriteString("用户的原始请求:\n")
|
|
if user := strings.TrimSpace(currentUserText(st.rc)); user == "" {
|
|
b.WriteString("(无)\n")
|
|
} else {
|
|
b.WriteString(user)
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n工作过程中的草稿回复(不要照抄过程,把它整理成给用户的回复):\n")
|
|
if strings.TrimSpace(draft) == "" {
|
|
b.WriteString("(无)\n")
|
|
} else {
|
|
b.WriteString(scrubInternal(draft))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n工具过程:\n")
|
|
if len(st.evidence) == 0 {
|
|
b.WriteString("(无)\n")
|
|
}
|
|
for i, ev := range st.evidence {
|
|
fmt.Fprintf(&b, "#%d %s %s %s\n", i+1, ev.Name, ev.Status, scrubInternal(ev.Summary))
|
|
}
|
|
b.WriteString("\n交付物:\n")
|
|
names := 0
|
|
for _, ref := range st.latestDeliverables() {
|
|
if ref == nil || strings.TrimSpace(ref.Name) == "" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "- %s\n", ref.Name)
|
|
names++
|
|
}
|
|
if names == 0 {
|
|
b.WriteString("(无)\n")
|
|
}
|
|
b.WriteString("\n检查结论:\n")
|
|
if len(st.checks) == 0 {
|
|
b.WriteString("(无)\n")
|
|
}
|
|
for _, c := range st.checks {
|
|
if c.Passed {
|
|
fmt.Fprintf(&b, "- 通过:%s\n", scrubInternal(c.Reason))
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "- 未通过:%s\n", scrubInternal(c.Reason))
|
|
}
|
|
if strings.TrimSpace(notice) != "" {
|
|
b.WriteString("\n仍未完成(必须在回复里如实说明,不要假装已经完成):\n")
|
|
b.WriteString(scrubInternal(notice))
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func scrubInternal(s string) string {
|
|
s = internalRef.ReplaceAllString(s, "")
|
|
s = strings.ReplaceAll(s, "()", "")
|
|
s = strings.ReplaceAll(s, "()", "")
|
|
return strings.TrimSpace(s)
|
|
}
|