diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go
index eb33a65..6b8b987 100644
--- a/internal/ctxasm/ctxasm.go
+++ b/internal/ctxasm/ctxasm.go
@@ -16,6 +16,10 @@ type Report struct {
EstChars int `json:"estChars"`
}
+// DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。
+// 16k 时代码值对会议纪要类长文档明显不足(正文大半被静默丢弃);48k ≈ 主流 128k 上下文的安全占比。
+const DefaultMaxAttachmentChars = 48000
+
// AssembleInput 组装模型输入消息。
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit) ([]agentllm.Message, *Report) {
rep := &Report{}
@@ -59,13 +63,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
}
}
if len(attachments) > 0 {
- sb.WriteString("\n\n[附件]\n")
+ limit := int(eff.GetNumber("context.maxAttachmentChars", DefaultMaxAttachmentChars))
+ if limit <= 0 {
+ limit = DefaultMaxAttachmentChars
+ }
+ sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n")
for _, doc := range attachments {
text := doc.PlainText()
- limit := 16000
- if len(text) > limit {
- text = text[:limit] + "…(附件超长截断)"
- rep.Truncations = append(rep.Truncations, "附件 "+doc.Meta.Filename+" 超长截断")
+ if runes := []rune(text); len(runes) > limit {
+ text = string(runes[:limit]) + "…(附件超长截断)"
+ rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
}
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
}
diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go
new file mode 100644
index 0000000..90542be
--- /dev/null
+++ b/internal/ctxasm/ctxasm_test.go
@@ -0,0 +1,68 @@
+package ctxasm
+
+import (
+ "strings"
+ "testing"
+
+ core "agent-system-core"
+)
+
+func fakeUser(text string) core.Message {
+ return core.Message{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: text}}}
+}
+
+func TestAssembleInputAttachmentLimit(t *testing.T) {
+ prof := core.AgentProfile{SystemPrompt: "s"}
+ long := strings.Repeat("会", 60000) // 60k 字符 > 默认 48k 上限
+ doc := core.ParsedDocument{
+ Meta: core.DocMeta{Filename: "纪要.docx"},
+ Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
+ }
+ msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil)
+
+ last := msgs[len(msgs)-1]
+ if !strings.Contains(last.Content, "纪要.docx") {
+ t.Fatal("attachment filename missing from model input")
+ }
+ if got := strings.Count(last.Content, "会"); got != DefaultMaxAttachmentChars {
+ t.Errorf("injected chars = %d, want %d (default limit)", got, DefaultMaxAttachmentChars)
+ }
+ if !strings.Contains(last.Content, "附件超长截断") {
+ t.Error("truncation marker missing in model input")
+ }
+ if len(rep.Truncations) != 1 || !strings.Contains(rep.Truncations[0], "纪要.docx") {
+ t.Errorf("truncation not observable in report: %v", rep.Truncations)
+ }
+}
+
+func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
+ prof := core.AgentProfile{SystemPrompt: "s"}
+ doc := core.ParsedDocument{
+ Meta: core.DocMeta{Filename: "a.txt"},
+ Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}},
+ }
+ eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
+ msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil)
+ last := msgs[len(msgs)-1]
+ if got := strings.Count(last.Content, "甲"); got != 100 {
+ t.Errorf("injected chars = %d, want 100 (configured limit)", got)
+ }
+ if len(rep.Truncations) != 1 {
+ t.Errorf("expected truncation record, got %v", rep.Truncations)
+ }
+}
+
+func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
+ prof := core.AgentProfile{SystemPrompt: "s"}
+ doc := core.ParsedDocument{
+ Meta: core.DocMeta{Filename: "b.txt"},
+ Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}},
+ }
+ msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil)
+ if len(rep.Truncations) != 0 {
+ t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
+ }
+ if !strings.Contains(msgs[len(msgs)-1].Content, strings.Repeat("乙", 100)) {
+ t.Error("full text should be injected")
+ }
+}
diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go
index 0a3c2ae..cb1d74a 100644
--- a/internal/ingest/ingest.go
+++ b/internal/ingest/ingest.go
@@ -133,13 +133,6 @@ func parsePlain(s string) []core.DocBlock {
// ---- docx(OOXML:zip 内 word/document.xml)----
-type xdoc struct {
- Body struct {
- Paragraphs []xpar `xml:"p"`
- Tables []xtbl `xml:"tbl"`
- } `xml:"body"`
-}
-
type xpar struct {
Props struct {
Style struct {
@@ -190,42 +183,70 @@ func parseDocx(content []byte) ([]core.DocBlock, error) {
if docXML == nil {
return nil, fmt.Errorf("word/document.xml not found")
}
- var doc xdoc
- if err := xml.Unmarshal(docXML, &doc); err != nil {
- return nil, fmt.Errorf("unmarshal document.xml: %w", err)
- }
+ return parseDocxBody(docXML)
+}
+
+// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地
+// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。
+func parseDocxBody(docXML []byte) ([]core.DocBlock, error) {
+ dec := xml.NewDecoder(bytes.NewReader(docXML))
var blocks []core.DocBlock
- for _, p := range doc.Body.Paragraphs {
- text := paragraphText(p)
- if strings.TrimSpace(text) == "" {
+ for {
+ tok, err := dec.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("decode document.xml: %w", err)
+ }
+ start, ok := tok.(xml.StartElement)
+ if !ok {
continue
}
- if lvl, ok := headingLevel(p.Props.Style.Val); ok {
- blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
- } else {
- blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
- }
- }
- for _, tbl := range doc.Body.Tables {
- var rows [][]string
- for _, tr := range tbl.Rows {
- row := []string{}
- for _, tc := range tr.Cells {
- cell := []string{}
- for _, p := range tc.Paragraphs {
- cell = append(cell, paragraphText(p))
- }
- row = append(row, strings.Join(cell, " "))
+ switch start.Name.Local {
+ case "p":
+ var p xpar
+ if err := dec.DecodeElement(&p, &start); err != nil {
+ return nil, fmt.Errorf("decode paragraph: %w", err)
+ }
+ text := paragraphText(p)
+ if strings.TrimSpace(text) == "" {
+ continue
+ }
+ if lvl, ok := headingLevel(p.Props.Style.Val); ok {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
+ } else {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
+ }
+ case "tbl":
+ var tbl xtbl
+ if err := dec.DecodeElement(&tbl, &start); err != nil {
+ return nil, fmt.Errorf("decode table: %w", err)
+ }
+ if rows := tableRows(tbl); len(rows) > 0 {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
}
- rows = append(rows, row)
- }
- if len(rows) > 0 {
- blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
}
}
return blocks, nil
}
+func tableRows(tbl xtbl) [][]string {
+ var rows [][]string
+ for _, tr := range tbl.Rows {
+ row := []string{}
+ for _, tc := range tr.Cells {
+ cell := []string{}
+ for _, p := range tc.Paragraphs {
+ cell = append(cell, paragraphText(p))
+ }
+ row = append(row, strings.Join(cell, " "))
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
func paragraphText(p xpar) string {
var sb strings.Builder
for _, r := range p.Runs {
diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go
index 79d6bfa..52ee616 100644
--- a/internal/ingest/ingest_test.go
+++ b/internal/ingest/ingest_test.go
@@ -60,6 +60,7 @@ func TestParseDocx(t *testing.T) {
项值
版本1.0
+ 表格之后的段落。
`
if f, err := zw.Create("word/document.xml"); err == nil {
@@ -73,17 +74,36 @@ func TestParseDocx(t *testing.T) {
if doc.Meta.Parser != "builtin:docx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
- var heading, para, table bool
+ var heading, para, table, afterTable bool
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockHeading:
heading = b.Level == 1 && b.Text == "总则"
case core.BlockPara:
- para = strings.Contains(b.Text, "医疗器械")
+ para = para || strings.Contains(b.Text, "医疗器械")
+ afterTable = afterTable || strings.Contains(b.Text, "表格之后的段落")
case core.BlockTable:
table = len(b.Rows) == 2 && b.Rows[1][1] == "1.0"
}
}
+ if !afterTable {
+ t.Error("paragraph after table missing (table must not reorder content)")
+ }
+ // 文档顺序:标题 → 段落 → 表格 → 表后段落(表格原位,不后置)。
+ var seq []core.DocBlockType
+ for _, b := range doc.Blocks {
+ seq = append(seq, b.Type)
+ }
+ want := []core.DocBlockType{core.BlockHeading, core.BlockPara, core.BlockTable, core.BlockPara}
+ if len(seq) != len(want) {
+ t.Fatalf("block count: got %v want %v", seq, want)
+ }
+ for i := range want {
+ if seq[i] != want[i] {
+ t.Errorf("block order: got %v want %v", seq, want)
+ break
+ }
+ }
if !heading || !para || !table {
t.Errorf("docx blocks wrong: heading=%v para=%v table=%v blocks=%+v", heading, para, table, doc.Blocks)
}
diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go
index b15154d..286162f 100644
--- a/internal/profiles/profiles.go
+++ b/internal/profiles/profiles.go
@@ -2,6 +2,8 @@
package profiles
import (
+ "reflect"
+
core "agent-system-core"
"agent-system-backend/internal/store"
)
@@ -11,6 +13,15 @@ const OwnerDefault = store.OwnerDefault
const DefaultProfileID = "default"
+// defaultSystemPrompt 默认画像系统提示:通用基线 + 文档任务输出要求。
+// 后者是纪要/总结质量的直接杠杆——不做输出要求时模型默认给"简短摘要"。
+const defaultSystemPrompt = `你是运行在 Agent-System 中的通用智能体助手。回答准确、结构清晰;需要外部信息时优先使用可用工具;产出文档时写入工件。
+
+依据用户上传文档执行任务(总结、会议纪要、问答、改写等)时:
+- 以文档全文为依据,覆盖其中的关键信息与细节,不遗漏要点,不臆造文档之外的内容;
+- 输出用 Markdown 结构化排版(标题、要点列表、表格),内容完整详实;除非用户明确要求压缩篇幅,不要自行省略;
+- 决议、待办、责任人与时间等逐项列出,重要结论可注明依据的文档位置。`
+
func DefaultProfile() core.AgentProfile {
maxTools := 40
maxTurns := 16
@@ -20,7 +31,7 @@ func DefaultProfile() core.AgentProfile {
Description: "内置默认画像:全部工具可用,HTTP 工具执行前确认(T0 基线)。",
Builtin: true,
Source: "builtin",
- SystemPrompt: "你是一个通用智能体助手,运行在 Agent-System 中。回答准确、简洁;需要外部信息时优先使用可用工具;产出文档时写入工件。",
+ SystemPrompt: defaultSystemPrompt,
Model: core.ModelRoute{},
Tools: core.ToolPolicy{Allow: []string{"*"}, Confirm: []string{"http."}},
Context: core.ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000},
@@ -33,10 +44,13 @@ type Service struct{ st *store.Store }
func New(st *store.Store) *Service { return &Service{st: st} }
-// EnsureDefault 幂等写入内置画像。
+// EnsureDefault 幂等写入内置画像;内置画像只读(Save 拒绝编辑),库中与内置定义
+// 有差异的行只可能来自更早版本的内置定义,因此差异时升级为当前内置定义。
func (s *Service) EnsureDefault() error {
- if _, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil {
- return nil
+ if cur, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil {
+ if reflect.DeepEqual(cur, DefaultProfile()) {
+ return nil
+ }
}
return s.st.SaveProfile(DefaultProfile())
}
diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go
index 220ab61..2b36692 100644
--- a/internal/runtime/llm.go
+++ b/internal/runtime/llm.go
@@ -85,6 +85,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if prof.Budget.MaxToolCalls != nil {
maxTools = *prof.Budget.MaxToolCalls
}
+ // 输出预算:画像 budget.maxTokens 配置时下发(0 = 走服务端默认,不限制输出长度)。
+ streamOpts := agentllm.StreamOptions{}
+ if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 {
+ streamOpts.MaxTokens = *prof.Budget.MaxTokens
+ }
toolCallsMade := 0
thinkingIdx, textIdx := 0, 0
@@ -98,7 +103,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
var turnThinking, turnText strings.Builder
var calls []agentllm.ToolCall
var usage agentllm.Usage
- ev := client.Stream(rc.Context(), route.Model, msgs, decls, agentllm.StreamOptions{})
+ ev := client.Stream(rc.Context(), route.Model, msgs, decls, streamOpts)
phaseEmitted := false
for e := range ev {
switch e.Kind {
@@ -249,7 +254,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key}
msgs := []agentllm.Message{
- {Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出简明结果摘要(不超过 300 字)。"},
+ {Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。"},
{Role: agentllm.RoleUser, Content: rc.Subtask()},
}
var sb strings.Builder