mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Phase B, part 1 of the agentic attack-class audit (issue #124). - Planner and cold-digest compactor: a code-owned untrusted-data tail after the rendered body. Worker summaries, facts, digests, traces and tool outputs are data; they cannot add goals, targets or scope, end the task, or be relayed through steer_work as instructions. - The #49 untrusted-content block moves out of the editable section [A] of the worker, mainagent, auto and pentest seeds into a code-owned tail, also applied to the reporter. A body that already carries the block verbatim (rows seeded before this change) does not get it twice. - Planner user message: a code-owned goal anchor after the situational block restates the task goal and the operation constraints and asks each new intent to name the goal id it serves. The message builder is factored into plannerRoundInput for pure tests. - bbot-recon skill: every event field is untrusted; only scope_distance == 0 reaches insert_assets; scope_distance > 0 stays an intel fact with source=third_party_intel, never auto-scoped; FINDING text is a lead. Tests: prompt_injection_seed_test.go checks that a PromptOverride without the block still renders the tail for every role, planner and compactor included, that the block is not duplicated, and that the planner input ends with the goal anchor. shipped_skills_test.go checks the bbot markers.
555 lines
18 KiB
Go
555 lines
18 KiB
Go
package agent
|
|
|
|
// cold-digest §4/§5/§7: the Compactor ties the pure algorithms (coldgraph.go)
|
|
// to the store (db/digest.go) and the LLM. It runs in two modes:
|
|
//
|
|
// maintain — cheap, synchronous, once per planner round: bump round_no,
|
|
// recompute hot/cold, stamp/clear cold_since_round (§2.3). This is
|
|
// the bookkeeping the planner does anyway; it never calls the LLM.
|
|
// minor/major — background, off the planner hot path (§7): group cold nodes
|
|
// and compress each ≥2 block into a digest via the LLM. minor folds
|
|
// only the not-yet-covered cold set (tiered append); major re-derives
|
|
// the whole grouping from source and merges fragments (§5.1/§5.2),
|
|
// reusing bodies whose signature is unchanged (§5.3).
|
|
//
|
|
// Concurrency: one compaction per task at a time (mutex), ≥cooldown between runs,
|
|
// and a commit-time liveness recheck drops any member that revived while the body
|
|
// was being generated so a digest never covers a hot node.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/norma/llm"
|
|
"github.com/Autumn-27/norma/transcript"
|
|
)
|
|
|
|
// Compactor performs background cold-node compaction for many explorations.
|
|
type Compactor struct {
|
|
prov llm.Provider
|
|
model string
|
|
params coldParams
|
|
n, m int // minor / major thresholds (§7 N=20, M=8)
|
|
cooldown time.Duration // min gap between compactions per task (§7 60s)
|
|
maxDur time.Duration // hard cap on one background compaction
|
|
|
|
mu sync.Mutex
|
|
running map[int64]bool
|
|
lastRun map[int64]time.Time
|
|
}
|
|
|
|
// NewCompactor builds a compactor. prov/model are used for the §4 body LLM call
|
|
// (same model the agent runs on, per §4). A nil Compactor is a safe no-op.
|
|
func NewCompactor(prov llm.Provider, model string) *Compactor {
|
|
return &Compactor{
|
|
prov: prov,
|
|
model: model,
|
|
params: defaultColdParams(),
|
|
n: 20,
|
|
m: 8,
|
|
cooldown: 60 * time.Second,
|
|
maxDur: 5 * time.Minute,
|
|
running: map[int64]bool{},
|
|
lastRun: map[int64]time.Time{},
|
|
}
|
|
}
|
|
|
|
// OnPlannerRound is the single entry the planner calls each wake-up. It bumps the
|
|
// round, maintains the cold stamps synchronously, then (if a threshold is hit and
|
|
// no compaction is running / cooling down) launches a background compaction that
|
|
// outlives this planner round.
|
|
func (c *Compactor) OnPlannerRound(ctx context.Context, ts *db.ExplorationStore) {
|
|
if c == nil || c.prov == nil || ts == nil {
|
|
return
|
|
}
|
|
round, uncompressed, activeDigests, err := c.maintain(ts)
|
|
if err != nil {
|
|
log.Printf("[compaction] maintain exp=%d: %v", ts.ID(), err)
|
|
return
|
|
}
|
|
needMinor := uncompressed >= c.n
|
|
needMajor := activeDigests >= c.m
|
|
if !needMinor && !needMajor {
|
|
return
|
|
}
|
|
if !c.tryStart(ts.ID()) {
|
|
return // already running, or within cooldown — the derived state is eventually consistent; compress next round
|
|
}
|
|
go func() {
|
|
defer c.finish(ts.ID())
|
|
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.maxDur)
|
|
defer cancel()
|
|
// Compression is a bare provider call (compress calls prov.Complete directly) and
|
|
// does not go through agentcore's session loop, so the ctx carries no session id;
|
|
// gateways that do prompt caching / sticky routing by session-id header
|
|
// (opencode zen returns 400 when x-opencode-session is missing) would not get it.
|
|
// Here we attach an id that is stable per exploration: all compression requests
|
|
// of one exploration share it, so the header is present, and llmrec can attribute
|
|
// this call's tokens back to that exploration (previously unrecordable).
|
|
bg = transcript.WithSessionID(bg, fmt.Sprintf("exp%d-compactor", ts.ID()))
|
|
if needMajor {
|
|
c.major(bg, ts)
|
|
} else {
|
|
c.minor(bg, ts)
|
|
}
|
|
}()
|
|
_ = round
|
|
}
|
|
|
|
// maintain bumps round_no, recomputes hot/cold over the whole graph, and applies
|
|
// the cold_since_round stamp/clear ops (§2.3). Returns the new round plus the
|
|
// counts that drive the trigger: how many eligible-cold nodes are not yet covered
|
|
// (minor) and how many active digests exist (major).
|
|
func (c *Compactor) maintain(ts *db.ExplorationStore) (round int64, uncompressed, activeDigests int, err error) {
|
|
round, err = ts.BumpRound()
|
|
if err != nil {
|
|
return
|
|
}
|
|
g, _, err := loadColdGraph(ts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
stamps, err := ts.ColdStamps()
|
|
if err != nil {
|
|
return
|
|
}
|
|
hot := g.hotSet()
|
|
structCold := g.structuralCold(hot)
|
|
ops := computeStampOps(structCold, stamps, round)
|
|
if err = ts.ApplyStampOps(toDBStampOps(ops)); err != nil {
|
|
return
|
|
}
|
|
applyStampsInPlace(stamps, ops)
|
|
elig := g.eligibleCold(structCold, stamps, round, c.params)
|
|
covered, err := ts.CoveredMembers()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for id := range elig {
|
|
if _, ok := covered[id]; !ok {
|
|
uncompressed++
|
|
}
|
|
}
|
|
ad, err := ts.ActiveDigests()
|
|
if err != nil {
|
|
return
|
|
}
|
|
activeDigests = len(ad)
|
|
return
|
|
}
|
|
|
|
// minor folds the not-yet-covered eligible-cold set into new digest segments
|
|
// (tiered append, §5). Existing digests are untouched.
|
|
func (c *Compactor) minor(ctx context.Context, ts *db.ExplorationStore) {
|
|
round, err := ts.RoundNo()
|
|
if err != nil {
|
|
return
|
|
}
|
|
g, nodeByID, err := loadColdGraph(ts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
stamps, err := ts.ColdStamps()
|
|
if err != nil {
|
|
return
|
|
}
|
|
cvers, err := ts.ContentVersions()
|
|
if err != nil {
|
|
return
|
|
}
|
|
covered, err := ts.CoveredMembers()
|
|
if err != nil {
|
|
return
|
|
}
|
|
hot := g.hotSet()
|
|
elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params)
|
|
uncompressed := map[int64]bool{}
|
|
for id := range elig {
|
|
if _, ok := covered[id]; !ok {
|
|
uncompressed[id] = true
|
|
}
|
|
}
|
|
blocks := g.group(uncompressed, c.params)
|
|
if len(blocks) == 0 {
|
|
return // this batch has no ≥2 connected/shared-parent block — nothing to fold (§7)
|
|
}
|
|
for _, b := range blocks {
|
|
c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, nil))
|
|
}
|
|
// A minor may have pushed the segment count over M → merge in the same run.
|
|
if ad, e := ts.ActiveDigests(); e == nil && len(ad) >= c.m {
|
|
c.major(ctx, ts)
|
|
}
|
|
}
|
|
|
|
// major re-derives the whole grouping from source over ALL eligible-cold nodes
|
|
// (§5.1 re-compress from source), then reconciles against the active digests by signature:
|
|
// unchanged blocks keep their digest (no LLM), stale digests are superseded, and
|
|
// new/changed blocks are compressed afresh. This is where tiered fragments of one
|
|
// direction merge and where "later became connected" blocks unify (§5.2).
|
|
func (c *Compactor) major(ctx context.Context, ts *db.ExplorationStore) {
|
|
round, err := ts.RoundNo()
|
|
if err != nil {
|
|
return
|
|
}
|
|
g, nodeByID, err := loadColdGraph(ts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
stamps, err := ts.ColdStamps()
|
|
if err != nil {
|
|
return
|
|
}
|
|
cvers, err := ts.ContentVersions()
|
|
if err != nil {
|
|
return
|
|
}
|
|
active, err := ts.ActiveDigests()
|
|
if err != nil {
|
|
return
|
|
}
|
|
hot := g.hotSet()
|
|
elig := g.eligibleCold(g.structuralCold(hot), stamps, round, c.params)
|
|
blocks := g.group(elig, c.params)
|
|
|
|
bySig := map[string]*db.Node{}
|
|
for _, d := range active {
|
|
sig, _ := digestSigGen(d)
|
|
bySig[sig] = d
|
|
}
|
|
desired := map[string]bool{}
|
|
var toCreate []block
|
|
for _, b := range blocks {
|
|
sig := blockSignature(b, cvers)
|
|
desired[sig] = true
|
|
if _, ok := bySig[sig]; ok {
|
|
continue // unchanged → reuse the existing digest, skip LLM (§5.3)
|
|
}
|
|
toCreate = append(toCreate, b)
|
|
}
|
|
// Supersede stale digests FIRST (atomic drop of their covers edges) so a member
|
|
// is never covered by both an old and a new digest (§5.1 one-member-one-digest).
|
|
var stale []int64
|
|
for _, d := range active {
|
|
sig, _ := digestSigGen(d)
|
|
if !desired[sig] {
|
|
stale = append(stale, d.ID)
|
|
}
|
|
}
|
|
if err := ts.SupersedeDigests(stale); err != nil {
|
|
log.Printf("[compaction] supersede exp=%d: %v", ts.ID(), err)
|
|
}
|
|
for _, b := range toCreate {
|
|
c.foldBlock(ctx, ts, g, b, nodeByID, cvers, c.generationFor(b, active))
|
|
}
|
|
}
|
|
|
|
// foldBlock compresses one block and writes its digest — with a commit-time
|
|
// liveness recheck (§ concurrency): between grouping and write the graph may have
|
|
// changed, so any member that has since gone hot (revived) is dropped from the
|
|
// covers set. If the block dissolves below K it is skipped.
|
|
func (c *Compactor) foldBlock(ctx context.Context, ts *db.ExplorationStore, g *coldGraph, b block, nodeByID map[int64]*db.Node, cvers map[int64]int, generation int) {
|
|
body, err := c.compress(ctx, g, b, nodeByID)
|
|
if err != nil {
|
|
log.Printf("[compaction] compress exp=%d block=%v: %v", ts.ID(), b.Members, err)
|
|
return
|
|
}
|
|
// Re-read fresh state and drop any member that revived while we compressed.
|
|
fresh, _, err := loadColdGraph(ts)
|
|
if err != nil {
|
|
return
|
|
}
|
|
freshHot := fresh.hotSet()
|
|
members := make([]int64, 0, len(b.Members))
|
|
for _, mID := range b.Members {
|
|
if !freshHot[mID] {
|
|
members = append(members, mID)
|
|
}
|
|
}
|
|
if len(members) < c.params.K {
|
|
return // block revived out from under us — leave those nodes hot, don't fold
|
|
}
|
|
final := block{Members: members, Anchors: b.Anchors}
|
|
payload := digestPayload(body, final, nodeByID, generation, blockSignature(final, cvers))
|
|
if _, err := ts.AddDigest(payload, members); err != nil {
|
|
log.Printf("[compaction] add digest exp=%d: %v", ts.ID(), err)
|
|
}
|
|
}
|
|
|
|
// generationFor computes a digest's re-digestion generation (§1): 1 for a fresh fold; for a major
|
|
// merge, max(generation) over the active digests that overlap this block's
|
|
// members, +1.
|
|
func (c *Compactor) generationFor(b block, active []*db.Node) int {
|
|
if len(active) == 0 {
|
|
return 1
|
|
}
|
|
memberSet := make(map[int64]bool, len(b.Members))
|
|
for _, m := range b.Members {
|
|
memberSet[m] = true
|
|
}
|
|
best := 0
|
|
for _, d := range active {
|
|
_, gen := digestSigGen(d)
|
|
for _, m := range digestMemberIDs(d) {
|
|
if memberSet[m] {
|
|
if gen > best {
|
|
best = gen
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return best + 1
|
|
}
|
|
|
|
// tryStart acquires the per-task compaction lock, honoring the cooldown.
|
|
func (c *Compactor) tryStart(expID int64) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.running[expID] {
|
|
return false
|
|
}
|
|
if t, ok := c.lastRun[expID]; ok && time.Since(t) < c.cooldown {
|
|
return false
|
|
}
|
|
c.running[expID] = true
|
|
return true
|
|
}
|
|
|
|
func (c *Compactor) finish(expID int64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.running[expID] = false
|
|
c.lastRun[expID] = time.Now()
|
|
}
|
|
|
|
// --- helpers: db ↔ coldgraph ---
|
|
|
|
// loadColdGraph reads the exploration's nodes + edges and builds the cold-graph
|
|
// view plus an id→node index (for summaries/payload during compression).
|
|
func loadColdGraph(ts *db.ExplorationStore) (*coldGraph, map[int64]*db.Node, error) {
|
|
// Compaction must see the WHOLE graph, not the default row caps — pass a very
|
|
// high limit so the LIMIT clause is effectively unbounded for real task sizes.
|
|
const allRows = 1 << 30
|
|
nodes, err := ts.Nodes(allRows)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
edges, err := ts.Edges(allRows)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cgNodes := make([]cgNode, 0, len(nodes))
|
|
byID := make(map[int64]*db.Node, len(nodes))
|
|
for _, n := range nodes {
|
|
cgNodes = append(cgNodes, cgNode{ID: n.ID, Kind: n.Kind, State: n.State})
|
|
byID[n.ID] = n
|
|
}
|
|
cgEdges := make([]cgEdge, 0, len(edges))
|
|
for _, e := range edges {
|
|
cgEdges = append(cgEdges, cgEdge{From: e.From, Rel: e.Rel, To: e.To})
|
|
}
|
|
return newColdGraph(cgNodes, cgEdges), byID, nil
|
|
}
|
|
|
|
func toDBStampOps(ops []stampOp) []db.StampOp {
|
|
out := make([]db.StampOp, len(ops))
|
|
for i, o := range ops {
|
|
out[i] = db.StampOp{ID: o.ID, Set: o.Set, Round: o.Round}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// applyStampsInPlace folds the just-applied ops into the in-memory stamp map so
|
|
// eligibility can be computed immediately without a re-read.
|
|
func applyStampsInPlace(stamps map[int64]*int64, ops []stampOp) {
|
|
for _, o := range ops {
|
|
if o.Set {
|
|
r := o.Round
|
|
stamps[o.ID] = &r
|
|
} else {
|
|
stamps[o.ID] = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- helpers: digest payload ---
|
|
|
|
// digestPayload builds the digest node payload (cold-digest §1): the body, the
|
|
// member ids split by kind (restore cache; source of truth is the covers edges),
|
|
// the anchor ids, the generation, and the change-detection signature.
|
|
func digestPayload(body string, b block, nodeByID map[int64]*db.Node, generation int, signature string) map[string]any {
|
|
var facts, intents []int64
|
|
for _, m := range b.Members {
|
|
if n := nodeByID[m]; n != nil && n.Kind == db.KindIntent {
|
|
intents = append(intents, m)
|
|
} else {
|
|
facts = append(facts, m)
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"body": body,
|
|
"member_ids": map[string]any{"facts": facts, "intents": intents},
|
|
"anchor_ids": b.Anchors,
|
|
"generation": generation,
|
|
"signature": signature,
|
|
}
|
|
}
|
|
|
|
func digestSigGen(n *db.Node) (string, int) {
|
|
var p struct {
|
|
Signature string `json:"signature"`
|
|
Generation int `json:"generation"`
|
|
}
|
|
_ = json.Unmarshal(n.Payload, &p)
|
|
return p.Signature, p.Generation
|
|
}
|
|
|
|
func digestMemberIDs(n *db.Node) []int64 {
|
|
var p struct {
|
|
MemberIDs struct {
|
|
Facts []int64 `json:"facts"`
|
|
Intents []int64 `json:"intents"`
|
|
} `json:"member_ids"`
|
|
}
|
|
_ = json.Unmarshal(n.Payload, &p)
|
|
return append(append([]int64{}, p.MemberIDs.Facts...), p.MemberIDs.Intents...)
|
|
}
|
|
|
|
// --- helpers: compression input + LLM (§4) ---
|
|
|
|
func nodeSummary(n *db.Node) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
var p map[string]any
|
|
if json.Unmarshal(n.Payload, &p) == nil {
|
|
if s, ok := p["summary"].(string); ok {
|
|
return s
|
|
}
|
|
if t, ok := p["text"].(string); ok {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nodeConfidence(n *db.Node) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
var p map[string]any
|
|
if json.Unmarshal(n.Payload, &p) == nil {
|
|
if c, ok := p["confidence"].(string); ok {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildCompressionInput renders the connected sub-graph for the §4 prompt:
|
|
// member nodes (summary + id + kind + state + confidence), the internal blood
|
|
// edges among members, and — for a §3.1 shared-parent group — the anchor parents
|
|
// as context ("common parent #p"), which are NOT members.
|
|
func buildCompressionInput(g *coldGraph, b block, nodeByID map[int64]*db.Node) string {
|
|
memberSet := make(map[int64]bool, len(b.Members))
|
|
for _, m := range b.Members {
|
|
memberSet[m] = true
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString("[Member nodes (to be compressed)]:\n")
|
|
for _, m := range b.Members {
|
|
n := nodeByID[m]
|
|
kind := "fact"
|
|
if n != nil && n.Kind == db.KindIntent {
|
|
kind = "intent"
|
|
}
|
|
state := ""
|
|
if n != nil {
|
|
state = n.State
|
|
}
|
|
line := fmt.Sprintf("- #%d [%s/%s] %s", m, kind, state, nodeSummary(n))
|
|
if conf := nodeConfidence(n); conf != "" {
|
|
line += fmt.Sprintf(" (confidence=%s)", conf)
|
|
}
|
|
sb.WriteString(line)
|
|
sb.WriteByte('\n')
|
|
}
|
|
// internal edges among members
|
|
var edgeLines []string
|
|
for _, m := range b.Members {
|
|
for _, to := range g.children[m] {
|
|
if memberSet[to] {
|
|
edgeLines = append(edgeLines, fmt.Sprintf("- #%d yields/derives→ #%d", m, to))
|
|
}
|
|
}
|
|
}
|
|
if len(edgeLines) > 0 {
|
|
sb.WriteString("\n[Lineage edges between members (parent→child)]:\n")
|
|
sort.Strings(edgeLines)
|
|
sb.WriteString(strings.Join(edgeLines, "\n"))
|
|
sb.WriteByte('\n')
|
|
}
|
|
if len(b.Anchors) > 0 {
|
|
sb.WriteString("\n[Common parents / context anchors (not members; only used to understand which intent these results were explored from)]:\n")
|
|
for _, a := range b.Anchors {
|
|
n := nodeByID[a]
|
|
state := ""
|
|
if n != nil {
|
|
state = n.State
|
|
}
|
|
fmt.Fprintf(&sb, "- #%d [%s] %s\n", a, state, nodeSummary(n))
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// compress runs the §4 body LLM call on one block. Uses the same model the agent
|
|
// runs on; thinking disabled (a pure summarization step).
|
|
func (c *Compactor) compress(ctx context.Context, g *coldGraph, b block, nodeByID map[int64]*db.Node) (string, error) {
|
|
req := llm.CompletionRequest{
|
|
System: []string{compressionSystem},
|
|
Messages: []llm.Message{llm.UserText(buildCompressionInput(g, b, nodeByID))},
|
|
MaxTokens: 1500,
|
|
Thinking: "disabled",
|
|
}
|
|
msg, _, _, err := c.prov.Complete(ctx, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
body := strings.TrimSpace(msg.Text())
|
|
if body == "" {
|
|
return "", fmt.Errorf("empty body from model")
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// compressionSystem is the compactor's full system prompt: the §4 body prompt plus
|
|
// the code-owned untrusted-data tail shared with the planner (#124).
|
|
const compressionSystem = compressionSystemPrompt + untrustedDataRules + compactorDataRule
|
|
|
|
// compressionSystemPrompt is the §4 body prompt.
|
|
const compressionSystemPrompt = `You are compressing a group of [interconnected] exploration nodes into one consolidated conclusion (body), so the planner can quickly grasp "what this area has already established".
|
|
|
|
The input is a connected sub-graph:
|
|
- Nodes: each line is the summary (one sentence) of an intent or a fact, with id, kind (intent/fact), state, and confidence (if any).
|
|
- Relations: lineage edges between nodes (A derived from B / A yields B), explaining how they chain together.
|
|
- If nodes share no direct lineage edge but belong to the same upstream intent (given separately as "common parent #p"), consolidate them as "what this intent (#p) explored" — common parents are only context anchors, not members to compress.
|
|
|
|
Write the body accordingly:
|
|
1. Synthesize, don't enumerate: follow the relations to chain the causality (which fact spawned which intent, which intent produced which conclusion), telling "what this area of exploration concluded" — do not copy each summary verbatim.
|
|
2. Preserve distinctions: state differing conclusions separately; don't blend them into one vague sentence.
|
|
3. Preserve evidence strength: mark conclusions carrying confidence as observed / inferred; for inferred negative/doubtful conclusions, point out they are only inferences and can be rechecked — don't write them as settled facts.
|
|
4. Include ids: annotate each conclusion with its source node ids (e.g. "…(#12,#28)"), so the planner can restore the original nodes by id.
|
|
5. State positively, write only what the input contains: no speculation, no judgments absent from the input.
|
|
6. Length adapts to content: short when there are few conclusions, long enough when there are many distinct ones — but overall significantly shorter than the sum of all input summaries.
|
|
|
|
Output only the body text itself.`
|