The Atlas BigLaw / Big Michael — documentation bound to its code
7 documents

Lawyer voice fingerprinting

Turn a lawyer's LinkedIn writing history into a reusable ToneProfile via a MapReduce of Haiku calls, then watch that voice get injected into every drafting agent and the final synthesis.

biglaw-go/internal/agents/base.go1083 lines
   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Discover Legal

package agents

import (
	"encoding/json"
	"fmt"
	"math"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"

	"github.com/discover-legal/biglaw-go/internal/adapters"
	"github.com/discover-legal/biglaw-go/internal/audit"
	"github.com/discover-legal/biglaw-go/internal/config"
	"github.com/discover-legal/biglaw-go/internal/cost"
	"github.com/discover-legal/biglaw-go/internal/providers"
	"github.com/discover-legal/biglaw-go/internal/routing"
	"github.com/discover-legal/biglaw-go/internal/strutil"
	"github.com/discover-legal/biglaw-go/internal/types"
)

// AgentContext carries round context into each agent's processing loop.
type AgentContext struct {
	RoundGoal        types.RoundGoal
	IncomingMessages []types.AgentMessage
	MemoryEntries    []types.MemoryEntry
	TaskDescription  string
	TaskID           string
	// DocumentIndex is a short, sanitized list of the task's documents (title + ID)
	// — the MAP of what is on the matter, not the territory. Agents pull verbatim
	// passages on demand via the search_knowledge tool, which keeps a small model's
	// context window lean and keeps quoting on the tool-calling path where the
	// citation gate verifies it. Empty when the task has no documents.
	DocumentIndex         string
	ToolRegistry          ToolRegistry
	KnowledgeStore        KnowledgeStore
	MemoryStore           MemoryStore
	OwnerID               string
	AssignedLawyerTone    *types.ToneProfile
	ResponsibleLawyerID   string
	ResponsibleLawyerName string
	MatterNumber          string
	ClientNumber          string
}

// ToolRegistry is the interface agents use to discover and execute tools.
type ToolRegistry interface {
	SchemasFor(toolNames []string) []providers.ToolParam
	Execute(name string, input map[string]interface{}, ctx ToolContext) (interface{}, error)
}

// KnowledgeStore is the interface agents use to search documents.
type KnowledgeStore interface {
	Search(query string, ownerID string, topK int) ([]types.SearchResult, error)
	GetFullText(docID string) (string, error)
	GetByID(docID string) *types.Document
}

// MemoryStore is the interface agents use to query inter-round memory.
type MemoryStore interface {
	Query(query string, taskID string, agentID string, beforeRound int, topK int) ([]types.MemoryEntry, error)
}

// ToolContext is forwarded from the agent into each tool call.
type ToolContext struct {
	KnowledgeStore      KnowledgeStore
	MemoryStore         MemoryStore
	TaskID              string
	OwnerID             string
	ResponsibleLawyerID string
}

// Agent wraps an AgentDefinition and runs the agentic loop.
type Agent struct {
	Def       types.AgentDefinition
	cfg       *config.Config
	providers *providers.Registry
	costs     *cost.Store
}

func NewAgent(def types.AgentDefinition, cfg *config.Config, prov *providers.Registry, costs *cost.Store) *Agent {
	return &Agent{Def: def, cfg: cfg, providers: prov, costs: costs}
}

// GenerateNeedOffer produces the per-round Need/Offer descriptors (Haiku).
func (a *Agent) GenerateNeedOffer(ctx AgentContext) (types.NeedDescriptor, types.OfferDescriptor, error) {
	tier := a.Def.Tier
	model := routing.SelectModel(a.cfg, routing.SelectParams{
		Tier:     &tier,
		TaskType: routing.TaskDescriptor,
	})
	prompt := buildNeedOfferPrompt(a.Def, ctx)
	resp, err := a.callModel(prompt, 200, model, ctx.TaskID, cost.ContextDescriptor)
	if err != nil {
		return types.NeedDescriptor{}, types.OfferDescriptor{}, err
	}
	need, offer := parseNeedOffer(resp, a.Def.ID)
	return need, offer, nil
}

// Process runs the agent's full agentic loop and returns findings.
func (a *Agent) Process(ctx AgentContext) ([]types.Finding, error) {
	startedAt := time.Now()

	if ctx.TaskID != "" {
		audit.Default.Write(audit.WriteRequest{
			Event:   "agent.processing",
			ActorID: orSystem(ctx.ResponsibleLawyerID),
			TaskID:  ctx.TaskID,
			AgentID: a.Def.ID,
			Data: map[string]interface{}{
				"agentName": a.Def.Name,
				"tier":      a.Def.Tier,
				"domain":    a.Def.Domain,
				"round":     ctx.RoundGoal.Round,
			},
		})
	}

	tier := a.Def.Tier
	taskType := inferTaskType(a.Def)
	complexity := routing.EstimateComplexity(ctx.RoundGoal.Description)
	model := routing.SelectModel(a.cfg, routing.SelectParams{
		Tier:       &tier,
		AgentType:  &a.Def.Type,
		TaskType:   taskType,
		Complexity: complexity,
	})

	prompt := buildProcessingPrompt(a.Def, ctx)
	maxTokens := 2500
	if a.Def.Tier == types.TierTool {
		maxTokens = 600
	} else if a.Def.Tier == types.TierRoot {
		maxTokens = 4000
	}

	// Grant the document-retrieval tools to every finding-producing agent when
	// the matter has documents, so grounding never depends on the agent
	// definition's own tool list (many ship none, and a no-tool agent can only
	// paraphrase from the document titles).
	allowed := a.Def.AllowedTools
	if a.cfg.Agents.GrantRetrievalTools && strings.TrimSpace(ctx.DocumentIndex) != "" {
		allowed = mergeTools(allowed, retrievalTools)
	}

	hasTools := ctx.ToolRegistry != nil && ctx.KnowledgeStore != nil &&
		ctx.MemoryStore != nil && ctx.TaskID != "" && len(allowed) > 0

	var findings []types.Finding
	if hasTools {
		passages, loopText, lerr := a.runAgenticLoop(prompt, maxTokens, model, ctx, allowed)
		if lerr != nil {
			return nil, lerr
		}
		// Staged finding generation: when the agent has retrieved source, transcribe
		// evidence and analyse it as SEPARATE calls (extract → analyse). The
		// monolithic loop entangles verbatim transcription with analysis in one
		// context, and a small model paraphrases under that load; staging keeps the
		// evidence verbatim by construction. With no retrieval (e.g. no documents),
		// fall back to parsing the loop's own output.
		if len(passages) > 0 {
			findings = a.stagedFindings(ctx, passages, model)
		} else {
			findings = parseFindings(loopText, a.Def)
		}
	} else {
		text, cerr := a.callModel(prompt, maxTokens, model, ctx.TaskID, cost.ContextTask)
		if cerr != nil {
			return nil, cerr
		}
		findings = parseFindings(text, a.Def)
	}
	for i := range findings {
		findings[i].Round = ctx.RoundGoal.Round
	}

	if ctx.TaskID != "" {
		for _, f := range findings {
			audit.Default.Write(audit.WriteRequest{
				Event:   "finding.produced",
				ActorID: orSystem(ctx.ResponsibleLawyerID),
				TaskID:  ctx.TaskID,
				AgentID: a.Def.ID,
				Data: map[string]interface{}{
					"findingId":      f.ID,
					"agentName":      a.Def.Name,
					"confidence":     f.Confidence,
					"round":          ctx.RoundGoal.Round,
					"contentPreview": strutil.Truncate(f.Content, 150),
				},
			})
		}
		audit.Default.Write(audit.WriteRequest{
			Event:      "agent.complete",
			ActorID:    orSystem(ctx.ResponsibleLawyerID),
			TaskID:     ctx.TaskID,
			AgentID:    a.Def.ID,
			DurationMs: ptr(time.Since(startedAt).Milliseconds()),
			Data: map[string]interface{}{
				"agentName":    a.Def.Name,
				"findingCount": len(findings),
				"round":        ctx.RoundGoal.Round,
			},
		})
	}
	return findings, nil
}

// ─── Agentic loop ─────────────────────────────────────────────────────────────

// runAgenticLoop drives the agent to RETRIEVE the matter's documents via
// search_knowledge and returns the verbatim passages it pulled (deduped). It does
// NOT produce findings — those come from the staged extract→analyse path. finalText
// is the model's own output, kept only as a fallback for the no-retrieval case.
func (a *Agent) runAgenticLoop(initialPrompt string, maxTokens int, model string, ctx AgentContext, allowed []string) ([]retrievedPassage, string, error) {
	toolSchemas := ctx.ToolRegistry.SchemasFor(allowed)
	toolCtx := ToolContext{
		KnowledgeStore:      ctx.KnowledgeStore,
		MemoryStore:         ctx.MemoryStore,
		TaskID:              ctx.TaskID,
		OwnerID:             ctx.OwnerID,
		ResponsibleLawyerID: ctx.ResponsibleLawyerID,
	}

	prov, err := a.providers.Get(model)
	if err != nil {
		return nil, "", err
	}
	bareModel := routing.ResolveModelID(model)

	msgs := []providers.Message{{Role: "user", Content: initialPrompt}}
	var finalText string
	retrieved := false
	var passages []retrievedPassage
	seen := map[string]bool{}
	hasDocs := strings.TrimSpace(ctx.DocumentIndex) != ""

	for iteration := 0; iteration < a.cfg.Agents.MaxToolIterations; iteration++ {
		resp, err := prov.Chat(providers.ChatParams{
			Model:       bareModel,
			MaxTokens:   maxTokens,
			System:      a.Def.SystemPrompt,
			Tools:       toolSchemas,
			Messages:    msgs,
			CacheSystem: true,
			Temperature: a.cfg.LLMTemperature,
		})
		if err != nil {
			return passages, finalText, err
		}

		a.recordCost(resp, model, cost.ContextTask, ctx.TaskID)

		for _, block := range resp.Content {
			if block.Type == providers.BlockText {
				finalText = block.Text
			}
		}

		if resp.StopReason == providers.StopEndTurn {
			// Nudge a weaker model back to the tools if it tries to finish without
			// retrieving — staging needs passages to extract evidence from.
			if a.cfg.Agents.RequireRetrieval && hasDocs && !retrieved &&
				iteration < a.cfg.Agents.MaxToolIterations-1 {
				msgs = append(msgs, providers.Message{Role: "assistant", Content: resp.Content})
				msgs = append(msgs, providers.Message{Role: "user", Content: "Call search_chunks now to retrieve relevant passages from the matter's documents before finishing."})
				continue
			}
			break
		}

		if resp.StopReason == providers.StopToolUse {
			msgs = append(msgs, providers.Message{Role: "assistant", Content: resp.Content})

			var toolResults []providers.ContentBlock
			for _, block := range resp.Content {
				if block.Type != providers.BlockToolUse {
					continue
				}
				var result interface{}
				if !contains(allowed, block.Name) {
					result = map[string]string{"error": fmt.Sprintf("tool '%s' not permitted", block.Name)}
				} else {
					result, err = ctx.ToolRegistry.Execute(block.Name, block.Input, toolCtx)
					if err != nil {
						result = map[string]string{"error": err.Error()}
					}
					if isRetrievalTool(block.Name) {
						retrieved = true
						for _, p := range extractPassages(result) {
							if !seen[p.text] {
								seen[p.text] = true
								passages = append(passages, p)
							}
						}
					}
				}
				raw, _ := json.Marshal(result)
				content := string(raw)
				if maxTok := a.cfg.Agents.MaxToolResultTokens; maxTok > 0 {
					if trimmed := strutil.TruncateToTokens(content, maxTok); len(trimmed) < len(content) {
						content = trimmed + "…[truncated]"
					}
				}
				toolResults = append(toolResults, providers.ContentBlock{
					Type:      providers.BlockToolResult,
					ToolUseID: block.ID,
					Content:   content,
				})
			}
			msgs = append(msgs, providers.Message{Role: "user", Content: toolResults})
			continue
		}
		break
	}
	return passages, finalText, nil
}

// retrievedPassage is one verbatim snippet the agent pulled, tagged with the
// document it came from (for the SOURCE= field + citation-gate resolution).
type retrievedPassage struct {
	source  string // document title/id
	text    string // verbatim snippet (what may be quoted — gate-safe)
	context string // optional: a table row's sheet + column headers, for understanding only
}

// extractPassages pulls the verbatim snippets out of a retrieval tool's result.
// Returns nil for tools that don't carry document snippets.
func extractPassages(result interface{}) []retrievedPassage {
	m, ok := result.(map[string]interface{})
	if !ok {
		return nil
	}
	rows, ok := m["results"].([]map[string]interface{})
	if !ok {
		return nil
	}
	var out []retrievedPassage
	for _, r := range rows {
		sn, _ := r["snippet"].(string)
		sn = strings.TrimSpace(sn)
		if sn == "" {
			continue
		}
		src, _ := r["title"].(string)
		if src == "" {
			if id, _ := r["id"].(string); id != "" {
				src = id
			} else {
				src = "source"
			}
		}
		ctx, _ := r["context"].(string)
		out = append(out, retrievedPassage{source: src, text: sn, context: strings.TrimSpace(ctx)})
	}
	return out
}

// ─── Staged finding generation: extract → analyse ──────────────────────────────
//
// A finding is a verbatim FACT (evidence) plus an INTERPRETATION (conclusion) —
// opposite cognitive modes. Generating both in one call lets a small model's
// "summariser" prior corrupt the transcription, so evidence comes out paraphrased
// (0% verbatim in the pipeline). Staging fixes it by construction:
//
//	EXTRACT (finer: one lean, persona-free, transcription-only call PER passage):
//	  copy the verbatim sentences relevant to the task. Each quote is verified to
//	  be a substring of its passage and LOCKED.
//	ANALYSE (fan-in, one call): write a conclusion per LOCKED quote, keyed by
//	  index. The model never re-emits the quote, so the evidence stays verbatim.
//
// Extraction is sequential for now (a single local GPU serialises requests
// anyway); the per-passage shape parallelises trivially on better hardware.
const extractSystemPrompt = "You are a verbatim evidence extractor. You copy exact sentences out of a source passage, character-for-character. You never paraphrase, summarise, interpret, shorten, or add words. You only transcribe."

const maxEvidencePerAgent = 8

// maxEvidencePassages caps how many retrieved passages go into the single batched
// extraction call, keeping the transcription prompt within the context window on
// small local models.
const maxEvidencePassages = 8

type extractedEvidence struct{ quote, source string }

var (
	// reQuoteLine matches "[n] QUOTE: ..." (n optional). Group 1 = passage index
	// (may be empty), group 2 = the quoted text.
	reQuoteLine = regexp.MustCompile(`(?im)^\s*(?:\[(\d+)\]\s*)?(?:[-*]\s*)?QUOTE:\s*(.+?)\s*$`)
	reConclLine = regexp.MustCompile(`(?im)^\s*\[?(\d+)\]?[.):\s-]*(?:Conclusion\s*:\s*)?(.+\S)\s*$`)
)

func (a *Agent) stagedFindings(ctx AgentContext, passages []retrievedPassage, model string) []types.Finding {
	focus := oneLine(ctx.TaskDescription)
	if rg := strings.TrimSpace(ctx.RoundGoal.Description); rg != "" {
		focus += " — " + oneLine(rg)
	}

	// Stage 1 — EXTRACT: one batched transcription call over all passages (a single
	// call so agents finish within the round timeout on local models).
	evidence := a.extractEvidenceBatch(focus, passages, model, ctx.TaskID)
	if len(evidence) == 0 {
		return nil
	}

	// Stage 2 — ANALYSE (fan-in): conclusion per LOCKED quote, by index.
	quotes := make([]string, len(evidence))
	sources := make([]string, len(evidence))
	for i, e := range evidence {
		quotes[i], sources[i] = e.quote, e.source
	}
	conclusions := a.analyseEvidence(ctx, quotes, sources, model)

	findings := make([]types.Finding, 0, len(evidence))
	for i, e := range evidence {
		concl := strings.TrimSpace(conclusions[i])
		if concl == "" {
			concl = "Evidence on point for this matter; see the quoted source."
		}
		findings = append(findings, types.Finding{
			ID:             uuid.New().String(),
			AgentID:        a.Def.ID,
			AgentName:      a.Def.Name,
			Content:        concl,
			Citations:      []types.Citation{{Source: e.source, Quote: e.quote, MechanicallyVerified: true}},
			Confidence:     0.8,
			EvidenceStatus: types.EvidenceGrounded,
			Timestamp:      time.Now(),
		})
	}
	return findings
}

// extractEvidenceBatch runs ONE lean, persona-free transcription call over ALL
// retrieved passages at once and returns the verbatim sentences it copied, each
// verified to be a substring of a source passage (anything paraphrased is
// dropped — grounding by construction). Batching keeps each agent to a single
// extraction call so it finishes within the round timeout on a local model.
func (a *Agent) extractEvidenceBatch(focus string, passages []retrievedPassage, model, taskID string) []extractedEvidence {
	prov, err := a.providers.Get(model)
	if err != nil || len(passages) == 0 {
		return nil
	}
	if len(passages) > maxEvidencePassages {
		passages = passages[:maxEvidencePassages]
	}
	var b strings.Builder
	for i, p := range passages {
		if p.context != "" {
			// Table row: show the column context so the model understands a cryptic
			// row, but it copies only the row text (the substring check below verifies
			// the quote against p.text, not the context).
			fmt.Fprintf(&b, "PASSAGE %d (table — %s):\n%s\n\n", i+1, p.context, p.text)
		} else {
			fmt.Fprintf(&b, "PASSAGE %d:\n%s\n\n", i+1, p.text)
		}
	}
	user := fmt.Sprintf("Task focus: %s\n\nBelow are %d source PASSAGES. From EACH passage, copy out up to 2 complete sentences OR table/data rows, WORD-FOR-WORD, that are most relevant to the task focus — INCLUDE specific figures, amounts, dates, percentages, account numbers, and citations exactly as written. Copy character-for-character — do not paraphrase, summarise, shorten, or fix anything. For a table row, copy the row text itself (not the parenthetical column context). Put each on its own line, prefixed with its passage number like:\n[1] QUOTE: <exact text>\nSkip any passage with nothing relevant. Output only QUOTE lines.\n\n%s", focus, len(passages), b.String())
	resp, err := prov.Chat(providers.ChatParams{
		Model:       routing.ResolveModelID(model),
		MaxTokens:   1200,
		System:      extractSystemPrompt,
		Messages:    []providers.Message{{Role: "user", Content: user}},
		CacheSystem: true,
		Temperature: a.cfg.LLMTemperature,
	})
	if err != nil {
		return nil
	}
	a.recordCost(resp, model, cost.ContextTask, taskID)
	var text string
	for _, bl := range resp.Content {
		if bl.Type == providers.BlockText {
			text = bl.Text
		}
	}
	npass := make([]string, len(passages))
	for i, p := range passages {
		npass[i] = normalizeWS(p.text)
	}
	var out []extractedEvidence
	seenQ := map[string]bool{}
	for _, m := range reQuoteLine.FindAllStringSubmatch(text, -1) {
		q := strings.TrimSpace(strings.Trim(strings.TrimSpace(m[2]), `"`))
		if q == "" {
			continue
		}
		nq := normalizeWS(q)
		if seenQ[nq] {
			continue
		}
		// Verify against the tagged passage first, then any passage; drop if it is
		// not a verbatim substring anywhere (paraphrase guard).
		src := ""
		if idx, e := strconv.Atoi(m[1]); e == nil && idx >= 1 && idx <= len(passages) && strings.Contains(npass[idx-1], nq) {
			src = passages[idx-1].source
		} else {
			for j := range npass {
				if strings.Contains(npass[j], nq) {
					src = passages[j].source
					break
				}
			}
		}
		if src == "" {
			continue
		}
		seenQ[nq] = true
		out = append(out, extractedEvidence{quote: q, source: src})
		if len(out) >= maxEvidencePerAgent {
			break
		}
	}
	return out
}

// analyseEvidence runs one fan-in call that writes a conclusion per locked quote,
// keyed by index. Returns conclusions aligned to the input order.
func (a *Agent) analyseEvidence(ctx AgentContext, quotes, sources []string, model string) []string {
	out := make([]string, len(quotes))
	prov, err := a.providers.Get(model)
	if err != nil {
		return out
	}
	var b strings.Builder
	for i := range quotes {
		fmt.Fprintf(&b, "[%d] (%s) %s\n", i+1, sources[i], oneLine(quotes[i]))
	}
	user := fmt.Sprintf("TASK: %s\nROUND GOAL (Round %d — %s): %s\n\nBelow are verbatim EVIDENCE quotes already extracted from the matter's documents. For EACH numbered item, write ONE concise CONCLUSION — your legal analysis of what that evidence shows for the task. Do NOT alter, re-quote, or merge the evidence; analyse each item on its own. Output exactly one line per item:\n[1] Conclusion: <your analysis>\n[2] Conclusion: <your analysis>\n\nEVIDENCE:\n%s",
		oneLine(ctx.TaskDescription), ctx.RoundGoal.Round, ctx.RoundGoal.Phase, oneLine(ctx.RoundGoal.Description), b.String())
	resp, err := prov.Chat(providers.ChatParams{
		Model:       routing.ResolveModelID(model),
		MaxTokens:   1500,
		System:      a.Def.SystemPrompt,
		Messages:    []providers.Message{{Role: "user", Content: user}},
		CacheSystem: true,
		Temperature: a.cfg.LLMTemperature,
	})
	if err != nil {
		return out
	}
	a.recordCost(resp, model, cost.ContextTask, ctx.TaskID)
	var text string
	for _, bl := range resp.Content {
		if bl.Type == providers.BlockText {
			text = bl.Text
		}
	}
	for _, m := range reConclLine.FindAllStringSubmatch(text, -1) {
		idx, e := strconv.Atoi(m[1])
		if e == nil && idx >= 1 && idx <= len(out) && strings.TrimSpace(out[idx-1]) == "" {
			out[idx-1] = strings.TrimSpace(m[2])
		}
	}
	return out
}

// oneLine collapses any run of whitespace (incl. newlines) to single spaces.
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }

// normalizeWS lowercases and collapses whitespace, for verbatim substring checks.
func normalizeWS(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }

// ─── callModel (single-shot) ──────────────────────────────────────────────────

func (a *Agent) callModel(userMsg string, maxTokens int, model string, taskID string, ctx cost.CostContext) (string, error) {
	prov, err := a.providers.Get(model)
	if err != nil {
		return "", err
	}
	resp, err := prov.Chat(providers.ChatParams{
		Model:       routing.ResolveModelID(model),
		MaxTokens:   maxTokens,
		System:      a.Def.SystemPrompt,
		Messages:    []providers.Message{{Role: "user", Content: userMsg}},
		CacheSystem: true,
		Temperature: a.cfg.LLMTemperature,
	})
	if err != nil {
		return "", err
	}
	a.recordCost(resp, model, ctx, taskID)
	for _, b := range resp.Content {
		if b.Type == providers.BlockText {
			return b.Text, nil
		}
	}
	return "", fmt.Errorf("no text in response from %s", model)
}

// ─── Cost recording ───────────────────────────────────────────────────────────

func (a *Agent) recordCost(resp *providers.ChatResponse, modelID string, ctx cost.CostContext, taskID string) {
	isLocal := routing.IsOllamaModel(modelID) || routing.IsLocalModel(modelID)
	bare := routing.ResolveModelID(modelID)

	var costUSD *float64
	var wh *float64
	var watts *int

	if !isLocal {
		cw, cr := 0, 0
		if resp.Usage.CacheWriteTokens != nil {
			cw = *resp.Usage.CacheWriteTokens
		}
		if resp.Usage.CacheReadTokens != nil {
			cr = *resp.Usage.CacheReadTokens
		}
		costUSD = cost.CalcCostUSD(bare, resp.Usage.InputTokens, resp.Usage.OutputTokens, cw, cr)
	} else {
		w := cost.CalcWattHours(a.cfg.Local.InferenceWatts, resp.DurationMs)
		wh = &w
		watts = &a.cfg.Local.InferenceWatts
	}

	provider := "anthropic"
	if routing.IsOllamaModel(modelID) {
		provider = "ollama"
	} else if routing.IsLocalModel(modelID) {
		provider = "local"
	}

	a.costs.Record(cost.RecordRequest{
		Model:            bare,
		Provider:         provider,
		InputTokens:      resp.Usage.InputTokens,
		OutputTokens:     resp.Usage.OutputTokens,
		CacheWriteTokens: resp.Usage.CacheWriteTokens,
		CacheReadTokens:  resp.Usage.CacheReadTokens,
		CostUSD:          costUSD,
		EstimatedWh:      wh,
		EstimatedWatts:   watts,
		DurationMs:       resp.DurationMs,
		Context:          ctx,
		TaskID:           taskID,
		AgentID:          a.Def.ID,
	})
}

// ─── Prompt builders ──────────────────────────────────────────────────────────

func buildNeedOfferPrompt(def types.AgentDefinition, ctx AgentContext) string {
	taskDesc := sanitize(ctx.TaskDescription)
	mem := "None yet."
	if len(ctx.MemoryEntries) > 0 {
		lines := make([]string, len(ctx.MemoryEntries))
		for i, e := range ctx.MemoryEntries {
			lines[i] = fmt.Sprintf("[Round %d] %s", e.Round, sanitize(e.Content))
		}
		mem = strings.Join(lines, "\n")
	}
	return fmt.Sprintf(`TASK: %s

CURRENT ROUND GOAL (Round %d, Phase: %s):
%s

YOUR ROLE: %s — %s

RELEVANT MEMORY FROM PRIOR ROUNDS:
%s

Output exactly:
NEED: <one sentence — what information or expertise you currently need from other agents>
OFFER: <one sentence — what you can contribute this round given your role>`,
		taskDesc, ctx.RoundGoal.Round, ctx.RoundGoal.Phase,
		sanitize(ctx.RoundGoal.Description), def.Name, def.Description, mem)
}

func buildProcessingPrompt(def types.AgentDefinition, ctx AgentContext) string {
	taskDesc := sanitize(ctx.TaskDescription)

	incoming := "No messages routed to you this round."
	if len(ctx.IncomingMessages) > 0 {
		parts := make([]string, len(ctx.IncomingMessages))
		for i, m := range ctx.IncomingMessages {
			parts[i] = fmt.Sprintf("[FROM: %s]\n%s", m.From, sanitize(m.Content))
		}
		incoming = strings.Join(parts, "\n\n---\n\n")
	}

	memory := "No prior memory."
	if len(ctx.MemoryEntries) > 0 {
		lines := make([]string, len(ctx.MemoryEntries))
		for i, e := range ctx.MemoryEntries {
			lines[i] = fmt.Sprintf("[Round %d — %s] %s", e.Round, e.Phase, sanitize(e.Content))
		}
		memory = strings.Join(lines, "\n")
	}

	expectedOutputs := ""
	for i, o := range ctx.RoundGoal.ExpectedOutputs {
		expectedOutputs += fmt.Sprintf("%d. %s\n", i+1, sanitize(o))
	}

	toneBlock := ""
	if def.Domain == types.DomainDrafting && ctx.AssignedLawyerTone != nil {
		toneBlock = "\n────────────────────────────────────────────────────────────────\n" +
			"ASSIGNED LAWYER TONE PROFILE — mirror this voice in all drafted output:\n" +
			sanitize(ctx.AssignedLawyerTone.InjectionSnippet) + "\n"
	}

	// The matter's documents are listed by title + ID only — the map, not the
	// territory. Agents pull verbatim passages from them via search_knowledge,
	// which keeps the context lean for a small local model and keeps quoting on
	// the tool-calling path the citation gate already verifies against.
	docIndexBlock := ""
	if strings.TrimSpace(ctx.DocumentIndex) != "" {
		docIndexBlock = "\n────────────────────────────────────────────────────────────────\n" +
			"DOCUMENTS ON THIS MATTER — call search_chunks to retrieve the relevant verbatim passages from these (or get_outline + read_section to navigate); when your point needs hard numbers or precise references (amounts, percentages, dates, counts, account numbers, statute cites) ALSO call extract_specifics to pull the exact figures from the exhibits. Copy your Evidence QUOTEs from what they return and set SOURCE= to the document id shown:\n" +
			sanitize(ctx.DocumentIndex) + "\n"
	}

	return fmt.Sprintf(`TASK: %s

ROUND GOAL (Round %d — Phase: %s):
%s

EXPECTED OUTPUTS THIS ROUND:
%s
INTER-ROUND MEMORY (what has been established in prior rounds):
%s

MESSAGES ROUTED TO YOU THIS ROUND (from other agents whose offers matched your needs):
%s
%s%s
────────────────────────────────────────────────────────────────
Produce your findings. Call the search_chunks tool to retrieve relevant verbatim
passages from the matter's documents (listed under DOCUMENTS ON THIS MATTER above
when present). For each finding, FIRST copy the exact supporting sentence from a
retrieved passage into the Evidence line, THEN state your Conclusion about it.
Copying the quote BEFORE you reason is required — it keeps the quote verbatim. Use
this EXACT format, copying the labels verbatim:

FINDING:
Evidence: SOURCE=<document ID or URL or case ECLI> | QUOTE=<a sentence copied character-for-character from that source> | PAGE=<page/para if known>
Conclusion: <what that evidence shows — your analysis, in your own words>
Confidence: <0.0–1.0>
END_FINDING

The Evidence and the Conclusion are different and are judged differently:
- Evidence QUOTE must appear verbatim in the source — copy it character-for-character. Do NOT summarise, reword, shorten, paraphrase, or fix typos; it is mechanically verified against the source and a reworded quote will not verify. Write the Evidence first so you copy real text before reasoning about it.
- Conclusion is YOUR analysis — write it in your own words; it need not match the source wording. NEVER put your analysis in a QUOTE.

Worked example:
FINDING:
Evidence: SOURCE=employment-agreement-2024 | QUOTE=Employee shall not engage in any competing business for two years | PAGE=7
Conclusion: The non-compete clause is unenforceable in California under Bus. & Prof. Code §16600, which voids contracts restraining lawful trade.
Confidence: 0.9
END_FINDING

Rules:
- Always close every finding with END_FINDING on its own line.
- Provide at least one Evidence line; add more Evidence lines for additional support.
- Reply with exactly NO_FINDINGS only if you genuinely have no findings this round.`,
		taskDesc, ctx.RoundGoal.Round, ctx.RoundGoal.Phase,
		sanitize(ctx.RoundGoal.Description), expectedOutputs, memory, incoming, toneBlock, docIndexBlock)
}

// ─── Response parsers ─────────────────────────────────────────────────────────

func parseNeedOffer(text, agentID string) (types.NeedDescriptor, types.OfferDescriptor) {
	needText := extractLine(text, "NEED:")
	offerText := extractLine(text, "OFFER:")
	if needText == "" {
		needText = "No specific need this round."
	}
	if offerText == "" {
		offerText = "General domain expertise available."
	}
	return types.NeedDescriptor{AgentID: agentID, Text: truncate(needText, 500)},
		types.OfferDescriptor{AgentID: agentID, Text: truncate(offerText, 500)}
}

// maxFindingsPerAgentRound caps how many findings one agent contributes per
// round, matching the original strict-parser bound.
const maxFindingsPerAgentRound = 3

// The finding grammar is forgiving by design: BigLaw must run on cheap/local
// models (qwen2.5:14b and the like) whose instruction-following is looser than
// a frontier model's. Such models routinely drop the END_FINDING terminator,
// omit the "Content:" label and write the finding as prose, decorate markers
// with markdown (**FINDING:**, ### FINDING, "FINDING 1:"), and emit citations
// in natural language instead of the SOURCE=/QUOTE= micro-format. None of that
// is a reasoning failure — it is formatting drift — so the parser recovers the
// work instead of discarding it on the floor.
var (
	// reFindingStart matches a finding header, decoration and ordinal allowed.
	reFindingStart = regexp.MustCompile(`(?im)^[\s>*#_-]*FINDING\b[ \t]*#?\d*[ \t]*:?`)
	reEndFinding   = regexp.MustCompile(`(?i)END_FINDING`)
	reNoFindings   = regexp.MustCompile(`(?i)\bNO_FINDINGS\b`)
	// reContent captures the conclusion. "Conclusion:" is the current label;
	// "Content:" is accepted for back-compat with older output.
	reContent = regexp.MustCompile(`(?si)\b(?:Conclusion|Content)\s*:\s*(.*?)(?:\n\s*(?:Evidence|Citation)\s*:|\n\s*Confidence\s*:|END_FINDING|$)`)
	// reCitationStrict is the verifiable gold form: SOURCE=/QUOTE= give the gate
	// a verbatim quote to check. "Evidence:" is the current label; "Citation:"
	// is accepted for back-compat.
	reCitationStrict = regexp.MustCompile(`(?si)(?:Evidence|Citation)\s*:\s*SOURCE\s*=\s*(.+?)\s*\|\s*QUOTE\s*=\s*(.+?)(?:\s*\|\s*PAGE\s*=\s*([^\n|]+))?(?:\n\s*(?:Evidence|Citation)\s*:|\n\s*Confidence\s*:|END_FINDING|$)`)
	// reCitationLoose is the fallback for natural evidence lines ("Evidence: the
	// LCA, p.3"). It yields a source but no verbatim quote, so the gate marks it
	// unverified rather than trusting it.
	reCitationLoose = regexp.MustCompile(`(?im)^[\s>*#_-]*(?:Evidence|Citation|Source|Cite)\s*:\s*(.+)$`)
	reConfidence    = regexp.MustCompile(`(?i)Confidence\s*:\s*([01]?(?:\.\d+)?)`)
	rePageLoose     = regexp.MustCompile(`(?i)\b(?:pp?\.?|page|para\.?|¶|§)\s*(\d{1,5})`)
	reQuoted        = regexp.MustCompile(`["“]([^"”]{3,})["”]`)
)

func parseFindings(text string, def types.AgentDefinition) []types.Finding {
	if reNoFindings.MatchString(text) {
		return nil
	}
	var findings []types.Finding
	for _, body := range splitFindingBlocks(text) {
		content := extractFindingContent(body)
		if content == "" {
			continue
		}
		confidence := 0.7
		if m := reConfidence.FindStringSubmatch(body); len(m) > 1 && m[1] != "" {
			if f, err := strconv.ParseFloat(m[1], 64); err == nil {
				// Clamp to [0,1] — an agent (or injected text echoed by one)
				// must not be able to claim out-of-range confidence.
				confidence = math.Min(1, math.Max(0, f))
			}
		}
		findings = append(findings, types.Finding{
			ID:         uuid.New().String(),
			AgentID:    def.ID,
			AgentName:  def.Name,
			Content:    content,
			Citations:  extractCitations(body),
			Confidence: confidence,
			Timestamp:  time.Now(),
		})
		if len(findings) >= maxFindingsPerAgentRound {
			break
		}
	}
	return findings
}

// splitFindingBlocks segments the response into per-finding bodies. It anchors
// on each FINDING header and runs to the next header or end of text; when an
// explicit END_FINDING terminator is present within a segment it trims to that
// (the frontier-model path), otherwise it keeps the whole segment (the cheap-
// model path that forgot to close the block).
func splitFindingBlocks(text string) []string {
	locs := reFindingStart.FindAllStringIndex(text, -1)
	if len(locs) == 0 {
		return nil
	}
	blocks := make([]string, 0, len(locs))
	for i, loc := range locs {
		end := len(text)
		if i+1 < len(locs) {
			end = locs[i+1][0]
		}
		body := text[loc[1]:end]
		if m := reEndFinding.FindStringIndex(body); m != nil {
			body = body[:m[0]]
		}
		blocks = append(blocks, body)
	}
	return blocks
}

// extractFindingContent returns the finding's prose. It prefers an explicit
// "Content:" label, and falls back to the block body with the trailing
// Citation/Confidence/marker lines stripped — which is how small models that
// skip the label still yield usable content.
func extractFindingContent(body string) string {
	if m := reContent.FindStringSubmatch(body); len(m) > 1 && strings.TrimSpace(m[1]) != "" {
		return strings.TrimSpace(m[1])
	}
	var keep []string
	for _, ln := range strings.Split(body, "\n") {
		t := strings.TrimSpace(ln)
		if t == "" {
			if len(keep) > 0 && keep[len(keep)-1] != "" {
				keep = append(keep, "")
			}
			continue
		}
		switch low := strings.ToLower(stripMarkerDecoration(t)); {
		case strings.HasPrefix(low, "evidence:"),
			strings.HasPrefix(low, "citation:"),
			strings.HasPrefix(low, "source:"),
			strings.HasPrefix(low, "cite:"),
			strings.HasPrefix(low, "confidence:"),
			strings.HasPrefix(low, "conclusion:"),
			strings.HasPrefix(low, "content:"):
			continue
		default:
			keep = append(keep, t)
		}
	}
	return strings.TrimSpace(strings.Join(keep, "\n"))
}

// extractCitations pulls the strict, verifiable SOURCE=/QUOTE= form first; only
// if none are present does it fall back to natural-language citation lines. The
// returned slice is non-nil even when empty — citations are part of the
// serialized JSON contract and nil marshals to null.
func extractCitations(body string) []types.Citation {
	citations := []types.Citation{}
	seen := map[string]bool{}
	for _, cm := range reCitationStrict.FindAllStringSubmatch(body, 50) {
		source := truncate(strings.TrimSpace(cm[1]), 200)
		var page *int
		if len(cm) > 3 {
			if p := digitsOf(cm[3]); p != "" {
				if n, err := strconv.Atoi(p); err == nil {
					page = &n
				}
			}
		}
		// Small models commonly wrap the quote in "…" and sometimes repeat
		// "QUOTE=" on one line instead of starting a new Citation. Split those
		// out and strip wrapping quote marks so the verbatim text matches the
		// source on the gate's substring check (otherwise a faithful quote is
		// falsely flagged as unverified).
		for _, qpart := range strings.Split(cm[2], "QUOTE=") {
			q := trimWrappingQuotes(strings.TrimRight(strings.TrimSpace(qpart), " |"))
			if q == "" {
				continue
			}
			c := types.Citation{Source: source, Quote: truncate(q, 500), Page: page}
			if key := c.Source + "\x00" + c.Quote; !seen[key] {
				seen[key] = true
				citations = append(citations, c)
			}
		}
	}
	if len(citations) > 0 {
		return citations
	}
	for _, cm := range reCitationLoose.FindAllStringSubmatch(body, 50) {
		raw := strings.TrimSpace(cm[1])
		// Skip a SOURCE=… line the strict pass already considered.
		if raw == "" || strings.HasPrefix(strings.ToUpper(raw), "SOURCE=") {
			continue
		}
		c := types.Citation{Source: truncate(raw, 200)}
		if q := reQuoted.FindStringSubmatch(raw); len(q) > 1 {
			c.Quote = truncate(strings.TrimSpace(q[1]), 500)
		}
		if pm := rePageLoose.FindStringSubmatch(raw); len(pm) > 1 {
			if n, err := strconv.Atoi(pm[1]); err == nil {
				c.Page = &n
			}
		}
		if key := c.Source; !seen[key] {
			seen[key] = true
			citations = append(citations, c)
		}
	}
	return citations
}

func stripMarkerDecoration(s string) string {
	return strings.TrimLeft(s, " \t>*#_-")
}

func digitsOf(s string) string {
	var b strings.Builder
	for _, r := range s {
		if r >= '0' && r <= '9' {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// trimWrappingQuotes removes matching leading/trailing quote marks (straight or
// typographic) that small models like to wrap verbatim quotes in. Repeats to
// peel nested pairs.
func trimWrappingQuotes(s string) string {
	s = strings.TrimSpace(s)
	pairs := map[rune]rune{'"': '"', '\'': '\'', '“': '”', '‘': '’', '«': '»'}
	for {
		r := []rune(s)
		if len(r) < 2 {
			return s
		}
		if closer, ok := pairs[r[0]]; ok && r[len(r)-1] == closer {
			s = strings.TrimSpace(string(r[1 : len(r)-1]))
			continue
		}
		return s
	}
}

// ─── Task type inference ──────────────────────────────────────────────────────

func inferTaskType(def types.AgentDefinition) routing.TaskType {
	if def.Tier == types.TierTool {
		return routing.TaskExtraction
	}
	if strings.Contains(def.ID, "drafter") || strings.Contains(def.ID, "writer") {
		return routing.TaskDrafting
	}
	if strings.Contains(def.ID, "analyst") || strings.Contains(def.ID, "agent") {
		return routing.TaskReasoning
	}
	if def.Type == types.AgentTypeRoot {
		return routing.TaskSynthesis
	}
	if def.Type == types.AgentTypeManager {
		return routing.TaskRouting
	}
	return routing.TaskReasoning
}

// ─── Utility ──────────────────────────────────────────────────────────────────

// sanitize neutralises protocol markers and control characters in untrusted
// content before prompt interpolation. Delegates to the shared sanitizer so
// the marker set stays in one place (mirrors TS base.ts importing
// sanitizePromptContent from adapters/lavern.ts).
func sanitize(s string) string {
	return adapters.SanitizePromptContent(s)
}

func extractLine(text, prefix string) string {
	re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(prefix) + `\s*(.+)`)
	if m := re.FindStringSubmatch(text); len(m) > 1 {
		return strings.TrimSpace(m[1])
	}
	return ""
}

func truncate(s string, max int) string {
	return strutil.Truncate(s, max)
}

func contains(slice []string, s string) bool {
	for _, v := range slice {
		if v == s {
			return true
		}
	}
	return false
}

// retrievalTools are the document-retrieval tools every finding-producing agent
// is granted when the matter has documents (config AGENT_GRANT_RETRIEVAL_TOOLS),
// so grounding never depends on a heterogeneous agent definition's own tool list.
var retrievalTools = []string{"search_chunks", "extract_specifics", "get_outline", "read_section", "search_knowledge", "read_document", "find_in_document", "list_documents"}

func isRetrievalTool(name string) bool { return contains(retrievalTools, name) }

// mergeTools unions two tool lists, preserving order and dropping duplicates.
func mergeTools(base, extra []string) []string {
	seen := make(map[string]bool, len(base)+len(extra))
	out := make([]string, 0, len(base)+len(extra))
	for _, t := range base {
		if t != "" && !seen[t] {
			seen[t] = true
			out = append(out, t)
		}
	}
	for _, t := range extra {
		if t != "" && !seen[t] {
			seen[t] = true
			out = append(out, t)
		}
	}
	return out
}

func orSystem(id string) string {
	if id == "" {
		return audit.ActorSystem
	}
	return id
}

func ptr[T any](v T) *T { return &v }