mecha-core 0.1.16

Provider-agnostic agent harness: loop, tools, MCP client, sessions.
Documentation
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
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
//! Tools: the things an agent can actually do.
//!
//! A tool is a name, a description, a JSON Schema, and an async function. The
//! registry holds them; MCP servers and native Rust functions both land here as
//! the same trait object, so the agent loop never learns the difference.

pub mod ask;
pub mod builtin;
pub mod recall;
pub mod skill;
pub mod todo;

use crate::config::{PermissionMode, SecurityConfig, ToolsConfig};
use crate::message::ToolSpec;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;

#[derive(Debug, Clone)]
pub struct ToolOutput {
    pub content: String,
    /// Returned to the model as `is_error: true` so it can recover rather than
    /// treating the failure as a result.
    pub is_error: bool,
    /// True when this content actually came from outside the machine.
    ///
    /// Distinct from the tool's declared `untrusted_input` capability, which
    /// says what the tool *can* return. A refusal generated by mecha's own
    /// guards is not third-party content, and labelling it as such makes the
    /// model invent explanations for its own harness's behaviour.
    pub external: bool,
    /// This error is mecha's own in-process guard refusing the call, not
    /// the tool failing — the harness working. The loop reads it into the
    /// trace's `denied`, so it lands on the same side of the failure
    /// accounting as an approver or hook denial: excluded from
    /// `ended_on_failed_call` and the tool-error rate `doctor` thresholds,
    /// exactly as the loop's own comment on that split demands. Only
    /// trusted in-process wrappers set it; nothing constructed from an MCP
    /// wire ever does — `mcp.rs` builds its outputs with the field
    /// explicitly `false`, and no wire byte reaches it — so a third-party
    /// server cannot launder its failures into "the harness working".
    pub refusal: bool,
}

impl ToolOutput {
    pub fn ok(content: impl Into<String>) -> Self {
        ToolOutput {
            content: content.into(),
            is_error: false,
            external: false,
            refusal: false,
        }
    }

    pub fn err(content: impl Into<String>) -> Self {
        ToolOutput {
            content: content.into(),
            is_error: true,
            external: false,
            refusal: false,
        }
    }

    /// An expected failure that is the harness refusing, not the tool
    /// failing — see the `refusal` field for what that changes.
    pub fn refusal(content: impl Into<String>) -> Self {
        ToolOutput {
            content: content.into(),
            is_error: true,
            external: false,
            refusal: true,
        }
    }

    /// Mark this content as having come from outside the machine.
    pub fn from_outside(mut self) -> Self {
        self.external = true;
        self
    }
}

/// What a tool can do — the vocabulary MCP standardized (`readOnly`,
/// `destructive`, `openWorld`) plus the two axes that decide whether an agent
/// can be turned into an exfiltration tool.
///
/// The *lethal trifecta* is private data + untrusted content + a way out. Any
/// agent holding all three can be instructed, by text hidden in the content it
/// reads, to take the private data and send it somewhere. Annotating tools on
/// these axes is what lets the loop refuse that combination structurally.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Capabilities {
    /// Returns data the user considers private.
    pub private_data: bool,
    /// Returns content a third party can influence — a web page, an email body,
    /// a calendar invite title. Treat everything it returns as hostile.
    pub untrusted_input: bool,
    /// Can transmit data outside the user's control. Note that a plain HTTP GET
    /// qualifies: the secret goes in the query string.
    pub external_send: bool,
    /// May destroy or overwrite data.
    pub destructive: bool,
}

impl Capabilities {
    pub fn private(mut self) -> Self {
        self.private_data = true;
        self
    }
    pub fn untrusted(mut self) -> Self {
        self.untrusted_input = true;
        self
    }
    pub fn sends(mut self) -> Self {
        self.external_send = true;
        self
    }
    pub fn destructive(mut self) -> Self {
        self.destructive = true;
        self
    }

    /// Everything either side declares.
    ///
    /// Union rather than assignment, because the only safe direction for an
    /// override is *wider*. Letting config narrow a tool's declared
    /// capabilities would disarm the interlock on the strength of a claim
    /// nothing enforces — the same mistake as a sandbox that silently degrades,
    /// and it would make the cheapest configuration the most dangerous one. A
    /// server that genuinely over-declares is what `TrifectaPolicy` is for: one
    /// deliberate, visible decision instead of a quiet per-server exemption.
    pub fn union(self, other: Capabilities) -> Self {
        Capabilities {
            private_data: self.private_data || other.private_data,
            untrusted_input: self.untrusted_input || other.untrusted_input,
            external_send: self.external_send || other.external_send,
            destructive: self.destructive || other.destructive,
        }
    }
}

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn input_schema(&self) -> Value;

    /// Read-only tools skip the approval gate and are safe to run in parallel.
    fn read_only(&self) -> bool {
        false
    }

    /// Declared risk surface. The default is the conservative one for a tool
    /// nobody has classified: assume it does nothing special.
    fn capabilities(&self) -> Capabilities {
        Capabilities::default()
    }

    async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;

    /// State this tool holds that a compaction must not lose.
    ///
    /// Compaction replaces the middle of a transcript with prose, and the
    /// measured failure mode is that a summariser preserves *what is true* and
    /// drops *how far you got*. Some of "how far you got" does not live in the
    /// messages at all — it lives in a tool — and for that state a summary is
    /// the wrong mechanism twice over: it is lossy, and the tool already has
    /// the exact current answer.
    ///
    /// So a tool may hand its state to the compaction to be carried across
    /// **verbatim**. Three rules make this safe rather than a second source of
    /// truth:
    ///
    /// - It is read at compaction time, so it is current by construction. A
    ///   stale copy is impossible because nothing stores one.
    /// - Exactly one copy survives: the carried block replaces the previous
    ///   one rather than accumulating beside it, or an old list would sit in
    ///   the prompt contradicting the new one.
    /// - It is for state the tool *owns*, not a summary of what happened. A
    ///   tool that returned prose here would be smuggling a second summariser
    ///   into the loop, unvalidated.
    ///
    /// `None` — the default — means "nothing worth carrying", which is the
    /// honest answer for every stateless tool.
    fn carried_state(&self, ctx: &ToolCtx) -> Option<CarriedState> {
        let _ = ctx;
        None
    }

    /// How the *operator* could make a call like the one just refused safe —
    /// one sentence appended to a trifecta denial, or `None` when nothing
    /// short of policy would change the answer.
    ///
    /// The interlock's refusal message has to route somewhere, and the loop
    /// cannot write that route: it sees capability bits, and the same
    /// `external_send: true` means "this is HTTP" on one tool and "the shell
    /// is unconfined" on another, with completely different fixes. The tool is
    /// the only party that knows which condition set the bit, so the tool
    /// carries the remedy — same division of labour as
    /// [`carried_state`](Tool::carried_state) and
    /// [`fixed_workspace`](Tool::fixed_workspace): the loop learns that a
    /// remedy exists, never what kind of tool it is talking to.
    ///
    /// This is the difference between a security posture that redirects work
    /// and one that dead-ends it. A refusal that names no exit teaches the
    /// operator to weaken policy (`trifecta = "allow"`), which is the worst
    /// possible outcome of a control that was working correctly. The measured
    /// case: `shell` denials in the TUI advised delegating to subagents, none
    /// of which had a shell — advice that could not work, for a call whose
    /// real fix (`[sandbox]`, one config section) went unmentioned.
    ///
    /// Addressed to the person, relayed by the model. It must not be an
    /// instruction the model could act on itself — "enable X in config.toml"
    /// is for hands on a keyboard, and a model that tried to do it would find
    /// config edits are not among its tools.
    fn denial_remedy(&self) -> Option<String> {
        None
    }

    /// The root this tool's relative paths actually resolve against, when the
    /// tool was constructed over a fixed directory rather than following the
    /// per-run [`ToolCtx`] workspace.
    ///
    /// Most tools return `None` — the default — because they resolve paths
    /// through the context they are called with. But a tool backed by a
    /// process spawned once for many runs (an MCP server) resolves relative
    /// paths against the directory it was spawned in, whatever workspace the
    /// current run carries. A staged (deferred) call records a jail so its
    /// release can rebuild the tool surface where the paths mean what they
    /// meant at drafting time — and for these tools that jail must be the
    /// spawn root, not the narrower per-run workspace, or every relative path
    /// in the draft resolves outside the release jail forever.
    ///
    /// Like [`carried_state`](Tool::carried_state), the loop learns only that
    /// some tools have a fixed root, never which kind of tool they are.
    fn fixed_workspace(&self) -> Option<PathBuf> {
        None
    }

    /// Tool names this tool is currently restricting the surface to, if it is
    /// restricting it at all.
    ///
    /// The third method in the family with [`carried_state`](Tool::carried_state)
    /// and [`fixed_workspace`](Tool::fixed_workspace), and it exists for the
    /// same reason: the loop learns that *some* tool may narrow the surface,
    /// never which kind of tool or why. A `skill` whose frontmatter names the
    /// tools its procedure needs is the first caller, and the loop stays
    /// unable to tell a skill from an MCP server.
    ///
    /// **Narrow only, never widen.** [`Registry::specs_for`] intersects the
    /// restriction with what it already holds, so a name here that matches no
    /// registered tool adds nothing — the same one-way rule as a config
    /// capability override, and for the same reason: a mechanism that could
    /// widen the surface by declaring a name would make the cheapest
    /// configuration the most dangerous one.
    ///
    /// `None` — the default — is "no opinion", which is what every stateless
    /// tool honestly has. Note that it is *not* the same as an empty list:
    /// nothing may restrict the surface to nothing, and the parser refuses an
    /// empty list upstream rather than leaving a run with no way to act.
    fn narrows_surface_to(&self) -> Option<Vec<String>> {
        None
    }

    /// Does this tool do its work in a conversation of its own?
    ///
    /// The fourth method in the family with
    /// [`carried_state`](Tool::carried_state),
    /// [`fixed_workspace`](Tool::fixed_workspace) and
    /// [`narrows_surface_to`](Tool::narrows_surface_to), and it exists for the
    /// same reason: the loop learns that *some* tool starts clean, never that
    /// subagents are a thing.
    ///
    /// One caller — boredom (`docs/GOAL-SYSTEM-DESIGN.md` §9.1 rung 3), where
    /// a fresh `Conversation` is the strongest available escape from a context
    /// that has talked itself into a corner. Nothing else could answer it: a
    /// delegate's *name* is whatever the user called it in config, and its
    /// capabilities are derived from its child's tools, so neither the
    /// registry nor the capability signature distinguishes it from an ordinary
    /// tool that happens to read the web. Naming one from the loop by string
    /// is the alternative, and is what this family exists to avoid.
    ///
    /// Not a security property and must never become one. It says where the
    /// work happens, not that anything is safer for happening there — the
    /// child's own taint, jail and approver decide that, and they are the
    /// parent's rules inherited rather than relaxed.
    fn runs_a_fresh_conversation(&self) -> bool {
        false
    }

    /// Drop state that belonged to the conversation that just ended.
    ///
    /// Most tools are stateless and the default no-op is honest for them. A
    /// tool that *is* stateful has a scope problem the registry cannot see:
    /// the registry belongs to the **agent**, and an agent can outlive a
    /// conversation — a batch item, a `/clear`, a Slack thread. State scoped
    /// to the conversation therefore has to be told when one ends, or it
    /// leaks into the next.
    ///
    /// The leak is not merely untidy where the state gates the tool surface:
    /// a `skill` narrowing that survived would constrain a task nobody had
    /// started yet. Same family as [`carried_state`](Tool::carried_state) —
    /// the loop learns that some tools have conversation-scoped state, never
    /// which ones or what it is.
    ///
    /// Note what this is *not*: an unload verb. Nothing calls it mid-run, and
    /// a procedure that has been read cannot be un-read.
    fn forget_conversation_state(&self) {}

    /// Does this handle refuse task closures (`status: done|dropped`) on the
    /// model's behalf?
    ///
    /// `false` for every ordinary tool — the default an MCP-wrapped tool can
    /// never override, which is the point: the closure guard's presence
    /// check used to read the tool's *description*, a string the guarded
    /// MCP server itself supplies, so a server whose description happened to
    /// end with the guard's own sentence was left unwrapped and still passed
    /// the startup verification — a fail-open keyed to data from the side
    /// being guarded. The answer lives in the type instead, where this
    /// repo's structural properties live; only the in-process wrapper
    /// returns `true`. Same family as [`carried_state`](Tool::carried_state):
    /// the loop and the verifier learn that some handle guards, never which
    /// kind of tool it is. The layering is deliberate and acknowledged: no
    /// `mecha-core` code reads this, and "task closure" is a CLI-domain
    /// concept — but the trait is the only channel a wire-supplied tool
    /// cannot fake, which is the property the check exists for, and
    /// [`Capabilities`] already carries domain concepts (`external_send`)
    /// into this trait on the same argument.
    fn guards_closures(&self) -> bool {
        false
    }

    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: self.name().to_string(),
            description: self.description().to_string(),
            input_schema: self.input_schema(),
        }
    }
}

/// A tool's own state, on its way across a compaction.
///
/// `label` names it in the rebuilt prompt (the tool's name is the obvious
/// choice); `body` is reproduced exactly, because verbatim is the whole point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CarriedState {
    pub label: String,
    pub body: String,
}

/// What a tool is allowed to touch.
#[derive(Debug, Clone)]
pub struct ToolCtx {
    /// Filesystem tools refuse paths outside this root.
    pub workspace: PathBuf,
    pub shell_timeout: std::time::Duration,
    pub security: SecurityConfig,
    /// The byte budget one *turn's* tool results share, divided equally
    /// across the calls in the batch so one runaway tool cannot starve its
    /// siblings (mecha executes a turn's calls concurrently, so they land
    /// together). The old per-tool cap was 200 KB — ~50k tokens, 1.5× the
    /// whole local context window, which is not a cap so much as a promise
    /// to overflow.
    pub output_budget_bytes: usize,
    /// Where an oversized result is saved in full before its transcript copy
    /// is cut. `None` disables spilling — the cut then names what was lost
    /// instead of where to find it. Per-context on purpose: two eval cases
    /// sharing one spill directory could read each other's output through it.
    pub spill_dir: Option<PathBuf>,
    /// The run's event channel, so a tool that *contains* a run — a subagent —
    /// can surface its progress instead of going dark until it returns.
    ///
    /// Display-only, and treat it that way: any tool (including a third-party
    /// MCP server's) can send fabricated events down this channel, so nothing
    /// that matters may key off it. Conversation state, taint, and run
    /// completion all come from the loop and the caller's join handle, never
    /// from events. Stamped by [`Agent::run_in`] per run; `None` everywhere
    /// nobody is watching (batch, eval).
    ///
    /// [`Agent::run_in`]: crate::agent::Agent::run_in
    pub events: Option<tokio::sync::mpsc::UnboundedSender<crate::agent::AgentEvent>>,
    /// The run's cancellation token. A tool that contains a run passes it on,
    /// so cancelling the parent actually cancels the child instead of politely
    /// waiting out its entire run. Stamped by `Agent::run_in`, like `events`.
    pub cancel: Option<tokio_util::sync::CancellationToken>,
    /// The run's phase. A tool that contains a run passes it on, so delegation
    /// is not the way to get a write executed from a planning run. Stamped by
    /// `Agent::run_in`, like `events`.
    pub phase: crate::agent::Phase,
    /// Tools the run this call belongs to may not dispatch, carried here so a
    /// tool that *contains* a run — a subagent — inherits the withholding
    /// instead of becoming the way around it. Same reasoning as `phase`
    /// directly above: delegating from a narrowed run must not widen it.
    pub withheld: std::sync::Arc<[String]>,
    /// The `tool_use` id of the call this context was built for. Stamped per
    /// dispatch (only when `events` is watched), so a tool that contains a
    /// run can tag its forwarded events with the call that spawned it — two
    /// subagents running in parallel are otherwise indistinguishable to a
    /// renderer.
    pub call_id: Option<String>,
    /// The conversation's taint as of this turn, stamped per dispatch when a
    /// mailbox is attached. The conservative pre-gate value — it includes
    /// what the *batch* can return, so a read and a `message_send` in one
    /// turn cannot stamp a clean label on the outgoing message. `None` means
    /// nobody stamped it, and a consumer must fail closed (treat it as fully
    /// tainted): a subagent's context, or any run wired outside the loop,
    /// must never pass as a clean sender by omission.
    pub taint: Option<crate::agent::Taint>,
    /// What the next request is predicted to cost, as of this turn.
    ///
    /// Run-scoped state a tool may read, like `taint` and `call_id` — and like
    /// them, the loop stamps it without knowing which tool cares. Only `todo`
    /// reads it today, because a plan is the one place a headroom number
    /// changes a decision; §4.3's rule is that most state belongs to the
    /// harness and never reaches the model at all.
    ///
    /// **Never the system prompt.** Render order is tools → system → messages
    /// with the cache breakpoint on the last system block, so a per-turn value
    /// there would re-pay the entire prefix, tools included, on every request.
    /// A tool result is where a changing reading is affordable.
    pub context: Option<crate::pressure::Forecast>,
    /// What this run has actually done, as of this call — the substrate step
    /// appraisal differences (`docs/GOAL-SYSTEM-DESIGN.md` §5.5).
    ///
    /// Stamped like `context` directly above and read by the same one tool,
    /// for a reason that generalises past `todo`: the loop owns the trace and
    /// a tool cannot see it, but only the tool holding a plan knows *which
    /// span* a number belongs to. So the loop supplies the counters and the
    /// tool supplies the boundaries.
    ///
    /// `None` means nobody stamped it — a subagent's context, a tool called
    /// outside the loop, a test — and a consumer must make no claim rather
    /// than read it as a run that did nothing. Zero work and no measurement
    /// are the opposite findings doctor's dash exists to keep apart.
    pub work: Option<crate::step::Work>,
    /// Set by the `compact` tool; read and cleared by the loop between turns.
    ///
    /// Shared rather than returned, on `cancel`'s precedent one field up: a
    /// tool cannot rewrite the transcript — it has no access to it — so what
    /// it can do is ask, and the loop is what acts. `None` where nothing
    /// registered the tool.
    pub compact_requested: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
    /// Set by the `todo` tool when a just-completed step is an escalation
    /// candidate (`docs/GOAL-SYSTEM-DESIGN.md` §5.5); read and cleared by the
    /// loop between turns, which makes the one quarantined call and folds a
    /// nudge into the turn if it says to.
    ///
    /// `compact_requested`'s exact shape, for the exact same reason: `todo`
    /// cannot rewrite the transcript or reach a provider, so what it can do
    /// is ask. `None` — not merely an empty slot — is what "this run has the
    /// feature off" means; presence is the enablement, like
    /// `compact_requested`'s own absence-is-the-off-switch.
    pub step_escalation:
        Option<std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>>,
}

impl Default for ToolCtx {
    fn default() -> Self {
        ToolCtx {
            workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            shell_timeout: std::time::Duration::from_secs(120),
            security: SecurityConfig::default(),
            output_budget_bytes: 24_000,
            spill_dir: fresh_spill_dir(),
            events: None,
            cancel: None,
            phase: crate::agent::Phase::default(),
            withheld: std::sync::Arc::from(Vec::new()),
            call_id: None,
            taint: None,
            context: None,
            work: None,
            compact_requested: None,
            step_escalation: None,
        }
    }
}

/// A spill directory no other context shares. Not created until first used.
fn fresh_spill_dir() -> Option<PathBuf> {
    Some(std::env::temp_dir().join(format!("mecha-spill-{}", uuid::Uuid::new_v4())))
}

impl ToolCtx {
    /// The same policy pointed at a different root. Used to give one run — an
    /// eval case, a batch item — its own isolated copy of a workspace without
    /// rebuilding the agent around it. The spill directory is re-derived too:
    /// a re-rooted context is a new isolation domain, and inheriting the old
    /// one would let its runs read each other's spilled output.
    pub fn with_workspace(&self, workspace: impl Into<PathBuf>) -> Self {
        ToolCtx {
            workspace: workspace.into(),
            spill_dir: fresh_spill_dir(),
            ..self.clone()
        }
    }

    /// Resolve a model-supplied path against the workspace and prove it stays
    /// inside. The path is untrusted input: `..`, symlinks, and absolute paths
    /// all have to be checked after canonicalization, not before.
    pub fn resolve(&self, raw: &str) -> Result<PathBuf> {
        let candidate = {
            let p = Path::new(raw);
            if p.is_absolute() {
                p.to_path_buf()
            } else {
                self.workspace.join(p)
            }
        };

        // The file may not exist yet (a write), so canonicalize the nearest
        // existing ancestor and re-append the rest.
        let mut existing = candidate.as_path();
        let mut trailing = Vec::new();
        let canonical_root = loop {
            match existing.canonicalize() {
                Ok(c) => break c,
                Err(_) => match existing.parent() {
                    Some(parent) => {
                        if let Some(name) = existing.file_name() {
                            trailing.push(name.to_owned());
                        }
                        existing = parent;
                    }
                    None => anyhow::bail!("cannot resolve path {raw:?}"),
                },
            }
        };
        let mut resolved = canonical_root;
        for part in trailing.iter().rev() {
            resolved.push(part);
        }

        let root = self
            .workspace
            .canonicalize()
            .unwrap_or_else(|_| self.workspace.clone());
        if resolved.starts_with(&root) {
            return Ok(resolved);
        }
        // The spill directory is the one sanctioned exception: oversized tool
        // output is saved there, and the truncation marker tells the model to
        // read the rest from exactly that path. Its contents are this
        // context's own tool results, so nothing new becomes reachable.
        if let Some(spill) = &self.spill_dir {
            let spill_root = spill.canonicalize().unwrap_or_else(|_| spill.clone());
            if resolved.starts_with(&spill_root) {
                return Ok(resolved);
            }
        }
        anyhow::bail!(
            "path {raw:?} resolves outside the workspace ({})",
            root.display()
        )
    }
}

/// Floor under a result's share of the turn budget. A wide batch must not
/// starve every result down to a marker with no content: below this, the
/// division stops and the total budget is allowed to overrun instead.
pub const SPILL_FLOOR_BYTES: usize = 4_096;

/// Cut an oversized tool result down to `cap` bytes, saving the full output
/// where the model can get it back.
///
/// The marker is written for the model, and it names the recovery — a
/// truncation notice that only says "gone" leaves the model to conclude the
/// rest never existed, and the elision line number is what makes the recovery
/// a single call instead of a scan. A failed spill degrades to a plain cut
/// that says the output was *not* saved; losing the tail must never lose the
/// run.
pub fn cap_result(
    content: String,
    cap: usize,
    spill_dir: Option<&Path>,
    tool: &str,
    id: &str,
) -> String {
    if content.len() <= cap {
        return content;
    }
    // Cut on a char boundary, never mid-codepoint.
    let mut cut = cap;
    while cut > 0 && !content.is_char_boundary(cut) {
        cut -= 1;
    }
    let head = &content[..cut];
    // The line the elision starts on. A cut mid-line means that same line —
    // re-reading from it overlaps a little, which is the right direction.
    let line = head.matches('\n').count() + 1;
    let total = content.len();

    let saved = spill_dir.and_then(|dir| {
        // Owner-only: spilled output is tool results in full — the same
        // sensitivity as the transcript, sitting in the shared temp dir.
        crate::create_private_dir(dir).ok()?;
        // A random component, because the call id alone can collide: batch
        // items and non-sandboxed eval cases share one context, and a local
        // server under a pinned seed can hand identical requests identical
        // call ids. A collision would silently overwrite, leaving one
        // conversation's marker pointing at another conversation's content.
        let tag = &uuid::Uuid::new_v4().to_string()[..8];
        let file = dir.join(format!("{}-{}-{tag}.txt", safe_name(tool), safe_name(id)));
        std::fs::write(&file, &content).ok()?;
        Some(file)
    });

    match saved {
        Some(path) => format!(
            "{head}\n\n[truncated by the harness: showing the first {cut} of {total} bytes; \
             the rest begins on line {line}. The full output is saved at {path} — continue \
             with fs_read {{\"path\": \"{path}\", \"offset\": {line}}}, or search it with \
             grep.]",
            path = path.display()
        ),
        None => format!(
            "{head}\n\n[truncated by the harness: {omitted} of {total} bytes were dropped \
             from line {line} on, and the full output could not be saved. Narrow the \
             request and re-run the tool if the rest is needed.]",
            omitted = total - cut
        ),
    }
}

/// Tool names and call ids become file names; anything else becomes `-`.
fn safe_name(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect()
}

/// The decision an approver hands back for one pending call.
///
/// Two ways to say no, and the difference is load-bearing rather than
/// cosmetic. The learning miner keys on the exact string `"Denied by the
/// user:"` to find corrections worth learning from, so **a refusal that no
/// human made must not wear that label**. `Blocked` is the machine's no — a
/// permission mode, a policy, a remote prompt nobody answered — and the loop
/// renders it as `"Blocked by policy:"`, joining `"Blocked by a hook:"` in
/// the family of refusals the miner ignores.
///
/// Without the split, a read-only run's refusals and a 2am approval nobody was
/// awake to answer both become training data attributed to a user who never
/// spoke. It is the same mistake as mining a publish's changed path as a voice
/// correction, and it was live in `ModeApprover` until a Slack approver needed
/// to express "nobody answered" and found there was no way to.
#[derive(Debug, Clone)]
pub enum Decision {
    Allow,
    /// A human said no. The reason is passed to the model so it can pick
    /// another approach — and it is mined as a correction.
    Deny(String),
    /// Machine policy said no, and no human was consulted. Never mined.
    Blocked(String),
}

/// Gates tool calls that aren't read-only. The CLI implements this with a
/// terminal prompt; a headless caller can auto-allow or auto-deny.
#[async_trait]
pub trait Approver: Send + Sync {
    async fn approve(&self, tool: &dyn Tool, input: &Value) -> Decision;
}

/// Answers from the configured [`PermissionMode`] without asking anyone.
pub struct ModeApprover {
    pub mode: PermissionMode,
}

#[async_trait]
impl Approver for ModeApprover {
    async fn approve(&self, tool: &dyn Tool, _input: &Value) -> Decision {
        match self.mode {
            PermissionMode::Allow => Decision::Allow,
            PermissionMode::ReadOnly if tool.read_only() => Decision::Allow,
            // `Blocked`, not `Deny`: a permission mode is policy this run was
            // started with, not a correction anybody made.
            PermissionMode::ReadOnly => Decision::Blocked(format!(
                "`{}` modifies state and this run is read-only",
                tool.name()
            )),
            // Nothing is watching to answer, so the safe reading of "ask" is no.
            PermissionMode::Ask => Decision::Blocked(format!(
                "`{}` needs approval and this run is non-interactive (use --yes to allow)",
                tool.name()
            )),
        }
    }
}

#[derive(Default)]
pub struct Registry {
    tools: BTreeMap<String, Arc<dyn Tool>>,
}

impl Registry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a tool. A later registration with the same name replaces the
    /// earlier one, so MCP servers can shadow built-ins deliberately.
    pub fn insert(&mut self, tool: Arc<dyn Tool>) {
        self.tools.insert(tool.name().to_string(), tool);
    }

    /// Take a tool back off the surface, by its exact registered name.
    ///
    /// For a run that must not be *able* to do something, as distinct from one
    /// asked not to. `tasks work` withholds `kg_task_update` this way: a run
    /// that can close its own assignment is a lane promoting itself, which is
    /// `ladder.rs`'s oldest rule and the same reason no `kg_accept` exists on
    /// the tool surface at all. A prompt saying "do not mark it done" is not
    /// the same control — it is advice to the party under test.
    pub fn remove(&mut self, name: &str) -> Option<Arc<dyn Tool>> {
        self.tools.remove(name)
    }

    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
        self.tools.get(name)
    }

    /// The tool a call may actually reach: registered **and** inside whatever
    /// restriction is currently active.
    ///
    /// Dispatch goes through this rather than [`get`](Registry::get), because
    /// a restriction that only shortened the spec list would be advisory. A
    /// model that saw `fs_write` three turns ago can still name it, and a
    /// narrowing enforced only in the list is one the model routes around by
    /// remembering — the same reason the phase filter makes tools genuinely
    /// absent rather than merely refused.
    pub fn available(&self, name: &str) -> Option<&Arc<dyn Tool>> {
        let tool = self.tools.get(name)?;
        match self.surface_restriction() {
            Some(allowed) if !allowed.contains(name) => None,
            _ => Some(tool),
        }
    }

    /// Names a call may reach right now, for the message that says so.
    pub fn available_names(&self) -> Vec<&str> {
        let restriction = self.surface_restriction();
        self.tools
            .values()
            .map(|t| t.name())
            .filter(|n| {
                restriction
                    .as_ref()
                    .is_none_or(|allowed| allowed.contains(*n))
            })
            .collect()
    }

    pub fn is_empty(&self) -> bool {
        self.tools.is_empty()
    }

    pub fn len(&self) -> usize {
        self.tools.len()
    }

    pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
        self.tools.values()
    }

    /// Everything the registered tools want carried across a compaction.
    ///
    /// In the registry's stable order, so a compaction does not reorder the
    /// prompt for a reason nobody can see. Asked of every tool, including an
    /// MCP server's — the loop does not learn which tools have state, only
    /// that some do, which is the same reason it never learns where a tool
    /// came from.
    ///
    /// The context is passed because one agent serves many conversations and a
    /// tool's state may be per-run: the compaction happening is *this* run's,
    /// so the state carried across it must be too. The loop still learns
    /// nothing about which tools those are — it hands over the run it is
    /// compacting and asks.
    pub fn carried_state(&self, ctx: &ToolCtx) -> Vec<CarriedState> {
        self.tools
            .values()
            .filter_map(|t| t.carried_state(ctx))
            .collect()
    }

    /// Specs in a stable order — the tool list is the very front of the prompt
    /// prefix, so reordering it would invalidate the cache on every request.
    pub fn specs(&self) -> Vec<ToolSpec> {
        self.tools.values().map(|t| t.spec()).collect()
    }

    /// Specs a given phase permits, in the same stable order.
    ///
    /// Note what this does to the prompt cache: planning sends a shorter tool
    /// list, so switching phase changes the front of the prefix and the next
    /// turn re-pays for it. That is the price of the tools being genuinely
    /// absent rather than merely refused, and it is the right trade.
    pub fn specs_for(&self, phase: crate::agent::Phase) -> Vec<ToolSpec> {
        let restriction = self.surface_restriction();
        self.tools
            .values()
            .filter(|t| phase.allows(t.read_only()))
            .filter(|t| {
                restriction
                    .as_ref()
                    .is_none_or(|allowed| allowed.contains(t.name()))
            })
            .map(|t| t.spec())
            .collect()
    }

    /// The names the surface is currently narrowed to, if anything is
    /// narrowing it.
    ///
    /// The **union** across everything that has an opinion, which is the only
    /// composition that lets two restrictions coexist: each names the tools
    /// its own procedure needs, and intersecting them would strand a run that
    /// loaded two skills. The invariant that matters is not "smallest" but
    /// "never larger than the unrestricted surface", and a union of subsets is
    /// still a subset — [`specs_for`](Registry::specs_for) intersects with
    /// what is registered, so a name nothing matches adds nothing.
    ///
    /// A tool that is *itself* restricting stays in the surface whatever it
    /// declared. Otherwise the first `skill` call could remove `skill`, and a
    /// procedure that says "then load the follow-up skill" would name a tool
    /// that had just been taken away — a restriction that eats its own
    /// mechanism is a trap rather than a policy.
    /// Tell every tool the conversation ended. See
    /// [`Tool::forget_conversation_state`].
    pub fn forget_conversation_state(&self) {
        for tool in self.tools.values() {
            tool.forget_conversation_state();
        }
    }

    pub fn surface_restriction(&self) -> Option<BTreeSet<String>> {
        let mut allowed: Option<BTreeSet<String>> = None;
        for tool in self.tools.values() {
            let Some(names) = tool.narrows_surface_to() else {
                continue;
            };
            let set = allowed.get_or_insert_with(BTreeSet::new);
            set.extend(names);
            set.insert(tool.name().to_string());
        }
        allowed
    }

    /// Register the built-ins permitted by config.
    ///
    /// The sandbox is passed in rather than read from config here because it
    /// changes what `shell` *is* — an unconfined shell and a confined one
    /// declare different capabilities, and the loop's interlock reads them.
    pub fn with_builtins(
        mut self,
        cfg: &ToolsConfig,
        sandbox: Arc<crate::sandbox::Sandbox>,
    ) -> Self {
        for tool in builtin::all(sandbox) {
            let name = tool.name();
            let allowed = cfg.enabled.is_empty() || cfg.enabled.iter().any(|e| e == name);
            let blocked = cfg.disabled.iter().any(|d| d == name);
            if allowed && !blocked {
                self.insert(tool);
            }
        }
        self
    }
}

#[cfg(test)]
mod cap_tests {
    use super::*;
    use serde_json::json;

    fn scratch(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("mecha-cap-{name}-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn a_result_under_the_cap_is_untouched() {
        let out = cap_result("short".into(), 100, None, "shell", "t1");
        assert_eq!(out, "short");
    }

    #[test]
    fn an_oversized_result_is_spilled_whole_and_the_marker_names_the_recovery() {
        let dir = scratch("spill");
        let body: String = (1..=100).map(|i| format!("line {i}\n")).collect();

        let out = cap_result(body.clone(), 200, Some(&dir), "shell", "t1");

        // The transcript copy is bounded...
        assert!(out.len() < body.len());
        assert!(out.starts_with("line 1\n"));
        // ...the disk copy is not: byte-identical, so nothing was lost. The
        // name carries a random tag, so it is discovered rather than assumed.
        let file = std::fs::read_dir(&dir)
            .unwrap()
            .next()
            .unwrap()
            .unwrap()
            .path();
        assert!(file
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .starts_with("shell-t1-"));
        assert_eq!(std::fs::read_to_string(&file).unwrap(), body);

        // The marker gives the model a single call back to the rest: the
        // path, and the line the elision starts on.
        let line = body[..200].matches('\n').count() + 1;
        assert!(out.contains(&file.display().to_string()), "{out}");
        assert!(out.contains(&format!("\"offset\": {line}")), "{out}");
        assert!(out.contains("fs_read"), "the recovery must be named: {out}");

        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn a_failed_spill_degrades_to_a_cut_that_admits_the_loss() {
        // A directory that cannot exist: spilling fails, the run must not.
        let impossible = PathBuf::from("/dev/null/not-a-dir");
        let body = "x".repeat(1000);
        let out = cap_result(body, 100, Some(&impossible), "shell", "t1");

        assert!(out.contains("could not be saved"), "{out}");
        assert!(
            out.contains("re-run the tool"),
            "the fallback still names a recovery: {out}"
        );
        assert!(
            !out.contains("/dev/null"),
            "no path is promised that does not exist"
        );
    }

    #[test]
    fn the_cut_lands_on_a_char_boundary() {
        // A cap that falls mid-codepoint must back up, not panic.
        let body = "é".repeat(100); // 2 bytes per char
        let out = cap_result(body, 33, None, "shell", "t1");
        assert!(out.starts_with(&"é".repeat(16)));
    }

    #[test]
    fn the_jail_admits_the_spill_directory_and_nothing_else_new() {
        let workspace = scratch("ws");
        let spill = scratch("spilldir");
        let ctx = ToolCtx {
            workspace: workspace.clone(),
            spill_dir: Some(spill.clone()),
            ..ToolCtx::default()
        };

        // The marker names an absolute spill path; fs_read must be able to
        // follow it, or the recovery the model was promised is a lie.
        std::fs::write(spill.join("shell-t1.txt"), "spilled").unwrap();
        let resolved = ctx
            .resolve(&spill.join("shell-t1.txt").display().to_string())
            .unwrap();
        assert!(resolved.ends_with("shell-t1.txt"));

        // The exception is the spill directory, not the temp dir around it.
        let elsewhere = std::env::temp_dir().join("mecha-cap-elsewhere.txt");
        std::fs::write(&elsewhere, "no").unwrap();
        assert!(ctx.resolve(&elsewhere.display().to_string()).is_err());

        // And with spilling disabled there is no exception at all.
        let no_spill = ToolCtx {
            workspace,
            spill_dir: None,
            ..ToolCtx::default()
        };
        assert!(no_spill
            .resolve(&spill.join("shell-t1.txt").display().to_string())
            .is_err());

        std::fs::remove_dir_all(&spill).ok();
        std::fs::remove_file(&elsewhere).ok();
    }

    #[test]
    fn a_rerooted_context_gets_its_own_spill_directory() {
        // Two eval cases sharing one spill directory could read each other's
        // output through it — the same isolation rule as the workspace copy.
        let ctx = ToolCtx::default();
        let rerooted = ctx.with_workspace(std::env::temp_dir());
        assert_ne!(ctx.spill_dir, rerooted.spill_dir);
    }

    /// A tool that declares a restriction, so the registry rules can be tested
    /// without a skill store on disk.
    struct Narrowing(&'static str, Option<Vec<String>>);

    #[async_trait]
    impl Tool for Narrowing {
        fn name(&self) -> &str {
            self.0
        }
        fn description(&self) -> &str {
            "test"
        }
        fn input_schema(&self) -> Value {
            json!({"type": "object"})
        }
        fn read_only(&self) -> bool {
            true
        }
        fn narrows_surface_to(&self) -> Option<Vec<String>> {
            self.1.clone()
        }
        async fn call(&self, _i: Value, _c: &ToolCtx) -> Result<ToolOutput> {
            Ok(ToolOutput::ok(""))
        }
    }

    fn registry_with(tools: Vec<Arc<dyn Tool>>) -> Registry {
        let mut r = Registry::new();
        for t in tools {
            r.insert(t);
        }
        r
    }

    #[test]
    fn nothing_narrows_until_something_says_so() {
        let r = registry_with(vec![
            Arc::new(Narrowing("a", None)),
            Arc::new(Narrowing("b", None)),
        ]);
        assert!(r.surface_restriction().is_none());
        assert_eq!(r.specs_for(crate::agent::Phase::Execute).len(), 2);
    }

    #[test]
    fn a_restriction_can_never_widen_the_surface() {
        // The invariant the whole mechanism rests on. `gate` names a tool that
        // is not registered; naming it must not conjure it, or a mechanism
        // that declares a name could add capability rather than remove it.
        let r = registry_with(vec![
            Arc::new(Narrowing("a", None)),
            Arc::new(Narrowing("b", None)),
            Arc::new(Narrowing(
                "gate",
                Some(vec!["a".into(), "not_registered".into()]),
            )),
        ]);
        let names: Vec<String> = r
            .specs_for(crate::agent::Phase::Execute)
            .into_iter()
            .map(|s| s.name)
            .collect();
        assert!(names.contains(&"a".to_string()));
        assert!(!names.contains(&"b".to_string()), "b was narrowed away");
        assert!(
            !names.iter().any(|n| n == "not_registered"),
            "a name nothing matches adds nothing: {names:?}"
        );
        assert!(
            names.contains(&"gate".to_string()),
            "the tool doing the narrowing stays reachable, or it eats its own mechanism"
        );
    }

    #[test]
    fn a_narrowed_tool_is_out_of_reach_for_dispatch_and_not_merely_unlisted() {
        // A shorter spec list alone would be advisory: a model that saw `b`
        // three turns ago can still name it.
        let r = registry_with(vec![
            Arc::new(Narrowing("a", None)),
            Arc::new(Narrowing("b", None)),
            Arc::new(Narrowing("gate", Some(vec!["a".into()]))),
        ]);
        assert!(r.available("a").is_some());
        assert!(r.available("b").is_none(), "narrowed away, so unreachable");
        assert!(
            r.get("b").is_some(),
            "still registered — `get` is a lookup, `available` is the gate"
        );
        assert!(!r.available_names().contains(&"b"));
    }

    #[test]
    fn two_restrictions_union_rather_than_intersect() {
        // Intersecting would strand a run that loaded two skills, each naming
        // what its own procedure needs. The union is still a subset of the
        // registered surface, which is the property that matters.
        let r = registry_with(vec![
            Arc::new(Narrowing("a", None)),
            Arc::new(Narrowing("b", None)),
            Arc::new(Narrowing("c", None)),
            Arc::new(Narrowing("g1", Some(vec!["a".into()]))),
            Arc::new(Narrowing("g2", Some(vec!["b".into()]))),
        ]);
        let allowed = r.surface_restriction().unwrap();
        assert!(allowed.contains("a") && allowed.contains("b"));
        assert!(!allowed.contains("c"), "still a subset: {allowed:?}");
    }
}