harn-stdlib 0.10.28

Embedded Harn standard library source catalog
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
import "std/agent/loop_call_resolution"
import "std/agent/loop_foundation"
import "std/agent/loop_resource_dispatch"
import "std/agent/loop_support"
import "std/agent/loop_tool_calls"
import "std/agent/loop_turn_options"

pub fn __scope_classifier_recent_context(messages, limit) {
  if type_of(messages) != "list" {
    return []
  }
  let cap = if type_of(limit) == "int" && limit > 0 {
    limit
  } else {
    3
  }
  const total = len(messages)
  let start = total - cap
  if start < 0 {
    start = 0
  }
  let out = []
  let i = start
  while i < total {
    const msg = messages[i]
    out = out.push({role: to_string(msg?.role ?? ""), content: msg?.content ?? msg?.text ?? ""})
    i = i + 1
  }
  return out
}

pub fn __scope_classifier_confidence(value, fallback) {
  const parsed = to_float(value ?? fallback)
  if parsed == nil {
    return fallback
  }
  if parsed < 0.0 {
    return 0.0
  }
  if parsed > 1.0 {
    return 1.0
  }
  return parsed
}

pub fn __scope_classifier_label(value) {
  const label = lowercase(trim(to_string(value ?? "")))
  if label == "in_scope" || label == "inscope" || label == "in-scope" {
    return "in_scope"
  }
  if label == "out_of_scope" || label == "outscope" || label == "out-of-scope" {
    return "out_of_scope"
  }
  if label == "escalate" || label == "ambiguous" || label == "uncertain" {
    return "escalate"
  }
  return "escalate"
}

pub fn __scope_classifier_normalize(raw, session_id, iteration) {
  if raw == nil {
    return nil
  }
  if type_of(raw) != "dict" {
    return {
      label: "escalate",
      original_label: "invalid",
      confidence: 0.0,
      confidence_threshold: 0.65,
      evidence: "scope classifier returned " + type_of(raw) + ", not a dict",
      session_id: session_id,
      iteration: iteration,
      skip_main_turn: false,
    }
  }
  const threshold = __scope_classifier_confidence(raw?.confidence_threshold ?? raw?.threshold, 0.65)
  const original_label = __scope_classifier_label(raw?.original_label ?? raw?.label)
  const confidence = __scope_classifier_confidence(raw?.confidence, 0.0)
  const label = if original_label != "escalate" && confidence < threshold {
    "escalate"
  } else {
    original_label
  }
  const evidence = trim(to_string(raw?.evidence ?? raw?.reason ?? raw?.reasoning ?? ""))
  return raw
    + {
    label: label,
    original_label: original_label,
    confidence: confidence,
    confidence_threshold: threshold,
    evidence: if evidence == "" {
      "no evidence provided"
    } else {
      evidence
    },
    session_id: session_id,
    iteration: iteration,
    skip_main_turn: raw?.skip_main_turn ?? true,
  }
}

pub fn __scope_classifier_fail_open(session_id, iteration, err) {
  return {
    label: "escalate",
    original_label: "error",
    confidence: 0.0,
    confidence_threshold: 0.65,
    evidence: "scope classifier failed: " + to_string(err),
    error: to_string(err),
    session_id: session_id,
    iteration: iteration,
    skip_main_turn: false,
  }
}

pub fn __run_input_guardrail(guardrail, session, message, turn_opts, iteration_index) {
  if guardrail == nil || iteration_index != 0 {
    return nil
  }
  let iteration = iteration_index + 1
  const messages = agent_session_messages(session.session_id)
  const payload = {
    session_id: session.session_id,
    session: {id: session.session_id},
    iteration: iteration,
    user_message: message,
    task: session?.task ?? message,
    messages: messages,
    recent_context: __scope_classifier_recent_context(messages, 3),
    provider: turn_opts?.provider ?? "",
    model: turn_opts?.model ?? "",
  }
  const outcome = try {
    guardrail(payload)
  }
  const verdict = if is_err(outcome) {
    const err = unwrap_err(outcome)
    if __agent_error_must_propagate(err) {
      throw err
    }
    __agent_input_guardrail_fail_open(
      err,
      nil,
      {session_id: session.session_id, iteration: iteration},
    )
  } else {
    __agent_input_guardrail_normalize(
      unwrap(outcome),
      nil,
      {session_id: session.session_id, iteration: iteration},
    )
  }
  agent_emit_event(session.session_id, "input_guardrail_verdict", verdict)
  return verdict
}

pub fn __input_guardrail_assistant_text(verdict) {
  const reason = trim(to_string(verdict?.reason ?? ""))
  if reason == "" {
    return "Input guardrail stopped this request before the agent loop."
  }
  return "Input guardrail stopped this request before the agent loop: " + reason
}

pub fn __input_guardrail_record_skip_turn(session, verdict, iteration_index) {
  let text = __input_guardrail_assistant_text(verdict)
  agent_session_record_assistant(
    session.session_id,
    {
      text: text,
      visible_text: text,
      provider: "harn",
      model: "input_guardrail",
      input_tokens: 0,
      output_tokens: 0,
      input_guardrail_verdict: verdict,
    },
  )
  agent_emit_event(
    session.session_id,
    "iteration_end",
    {
      iteration: iteration_index + 1,
      iteration_info: __iteration_info_payload(
        {
          text: text,
          visible_text: text,
          provider: "harn",
          model: "input_guardrail",
          input_tokens: 0,
          output_tokens: 0,
        },
        0,
        text,
      )
        + {
        dispatch_skipped: true,
        skip_reason: "input_guardrail_tripwire",
        input_guardrail_verdict: verdict,
      },
    },
  )
  let _ = __agent_loop_checkpoint(
    session.session_id,
    "iteration_end",
    {iteration: iteration_index + 1},
  )
  return text
}

pub fn __run_pre_turn_scope_classifier(classifier, session, message, turn_opts, iteration_index) {
  if classifier == nil {
    return nil
  }
  let iteration = iteration_index + 1
  const messages = agent_session_messages(session.session_id)
  const anchor = agent_session_workspace_anchor(session.session_id)
  const payload = {
    session_id: session.session_id,
    session: {id: session.session_id},
    iteration: iteration,
    user_message: message,
    task: session?.task ?? message,
    messages: messages,
    recent_context: __scope_classifier_recent_context(messages, 3),
    workspace_anchor: anchor,
    provider: turn_opts?.provider ?? "",
    model: turn_opts?.model ?? "",
  }
  const outcome = try {
    classifier(payload)
  }
  const verdict = if is_err(outcome) {
    const err = unwrap_err(outcome)
    if __agent_error_must_propagate(err) {
      throw err
    }
    __scope_classifier_fail_open(session.session_id, iteration, err)
  } else {
    __scope_classifier_normalize(unwrap(outcome), session.session_id, iteration)
  }
  if verdict != nil {
    agent_emit_event(session.session_id, "scope_classifier_verdict", verdict)
  }
  return verdict
}

/**
 * Cancellation and internal engine bugs propagate; only a genuine
 * classifier failure fails open (never let a mis-wired builtin quietly
 * disable the scope gate).
 *
 * @effects: []
 * @errors: []
 */
pub fn __scope_classifier_mounted_roots(anchor) {
  const roots = anchor?.additional_roots ?? []
  if type_of(roots) != "list" || len(roots) == 0 {
    return "  (none)"
  }
  let lines = []
  for root in roots {
    lines = lines.push(
      "  - " + to_string(root?.path ?? root?.root ?? "")
        + " (mount_mode: "
        + to_string(root?.mount_mode ?? "")
        + ")",
    )
  }
  return join(lines, "\n")
}

pub fn __scope_classifier_alert_body(verdict, session) {
  const anchor = verdict?.workspace_anchor ?? agent_session_workspace_anchor(session.session_id)
  const primary = to_string(anchor?.primary ?? "(none)")
  return "<scope-alert>\nThe latest user turn appears outside the current workspace anchor. "
    + to_string(
    verdict?.evidence ?? "",
  )
    + "\n\nCurrent anchor: "
    + primary
    + "\nMounted roots:\n"
    + __scope_classifier_mounted_roots(anchor)
    + "\n\nThree options:\n"
    + "  - add_root: mount the target repo into this session with `agent_session_add_root(session_id, root, {mount_mode})`\n"
    + "  - reanchor: switch the session primary anchor with `agent_session_reanchor(session_id, new_anchor)`\n"
    + "  - fork: spawn a sub-agent against the target repo with `spawn_agent({anchor: new_anchor, ...})`\n\n"
    + "Ask the user which handoff they prefer before doing workspace-mutating work.\n</scope-alert>"
}

pub fn __scope_classifier_assistant_text(verdict) {
  const evidence = trim(to_string(verdict?.evidence ?? ""))
  const suffix = if evidence == "" {
    ""
  } else {
    " " + evidence
  }
  return "This task appears to be outside the current workspace anchor."
    + suffix
    + " Options: 1) Add Root, 2) Re-anchor, 3) Fork to a new session. Which would you prefer?"
}

pub fn __scope_classifier_skip_main(verdict) {
  return verdict != nil && verdict?.label == "out_of_scope" && (verdict?.skip_main_turn ?? true)
}

pub fn __scope_classifier_record_skip_turn(session, verdict, iteration_index) {
  let text = __scope_classifier_assistant_text(verdict)
  agent_session_record_assistant(
    session.session_id,
    {
      text: text,
      visible_text: text,
      provider: "harn",
      model: "scope_classifier",
      input_tokens: 0,
      output_tokens: 0,
      scope_classifier_verdict: verdict,
    },
  )
  agent_session_inject(
    session.session_id,
    transcript_reminder_event(
      {
        body: __scope_classifier_alert_body(verdict, session),
        source: "in_pipeline",
        tags: ["scope_alert", "pre_turn_scope_classifier"],
        dedupe_key: "scope_alert:pre_turn:" + substring(sha256(session.session_id + text), 0, 16),
        ttl_turns: 3,
        fired_at_turn: iteration_index + 1,
      },
    ),
  )
  agent_emit_event(
    session.session_id,
    "iteration_end",
    {
      iteration: iteration_index + 1,
      iteration_info: __iteration_info_payload(
        {
          text: text,
          visible_text: text,
          provider: "harn",
          model: "scope_classifier",
          input_tokens: 0,
          output_tokens: 0,
        },
        0,
        text,
      )
        + {
        dispatch_skipped: true,
        skip_reason: "scope_classifier_out_of_scope",
        scope_classifier_verdict: verdict,
      },
    },
  )
  let _ = __agent_loop_checkpoint(
    session.session_id,
    "iteration_end",
    {iteration: iteration_index + 1},
  )
  return text
}

pub fn __agent_loop_pop_structural_veto_turn(session_id) {
  const messages = agent_session_messages(session_id)
  if len(messages) == 0 {
    return false
  }
  const last = messages[len(messages) - 1]
  if last?.role != "assistant" {
    return false
  }
  return agent_session_pop_last_assistant(session_id)
}

/**
 * Build the one-shot directive injected as a final system fragment when the
 * loop terminates mid-tool-use. It tells the model the turn/budget is spent,
 * that no more tool calls are possible, and how to wrap up — honoring the
 * configured `done_sentinel` when present.
 */
/**
 * The response-protocol tail every wrap-up directive ends with, so the reply
 * parses as a terminal turn (a bare `<user_response>` when no done-sentinel is
 * configured, or the `<user_response>` + `<done>SENTINEL</done>` pair when one
 * is). Factored out so the default directive and a host-supplied directive share
 * one source of truth for the protocol shape.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_wrapup_response_protocol_tail(opts) {
  const sentinel = opts?.done_sentinel
  if sentinel == nil || to_string(sentinel) == "" {
    return " End with a concise `<user_response>...</user_response>` containing the final answer."
  }
  const tag = to_string(sentinel)
  return " Emit a concise `<user_response>...</user_response>` with the final answer, then end with "
    + "`<done>"
    + tag
    + "</done>` exactly, as instructed by the response protocol."
}

/**
 * Outcome-authority constraint appended to a HOST-supplied wrap-up directive.
 *
 * The terminal outcome (budget/turn exhaustion, stuck, verify-capped) is decided
 * by the loop and stated to the model as fact; the wrap-up narrates it, it does
 * NOT re-adjudicate it. A host directive may forget to say so, so the loop always
 * appends this — a wrap-up that claims "I completed the task" on a failed run is
 * the exact failure this guards against.
 */
pub const __AGENT_LOOP_WRAPUP_OUTCOME_AUTHORITY: string =
  "The run's terminal outcome is already decided and is stated as fact in this instruction and the context above; narrate it truthfully and do NOT re-judge it or claim success on a run that did not succeed."

/**
 * The wrap-up directive for a terminal turn.
 *
 * Default (no `opts.wrapup_directive`): today's generic budget/turn-limit
 * summary directive, byte-identical to before, injected into the wrap-up SYSTEM
 * prompt by the caller.
 *
 * Host-supplied (`opts.wrapup_directive` non-empty): the host owns the wording
 * (product-specific sad-path narration, few-shot, language-generic framing). The
 * loop composes it with the outcome-authority constraint and the shared
 * response-protocol tail. The caller renders THIS variant as a trailing user
 * message (keeping the system prompt byte-identical for prefix-cache reuse),
 * never into the system prompt.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_wrapup_directive(opts) {
  const host_directive = trim(to_string(opts?.wrapup_directive ?? ""))
  const tail = __agent_loop_wrapup_response_protocol_tail(opts)
  if host_directive != "" {
    return host_directive + " " + __AGENT_LOOP_WRAPUP_OUTCOME_AUTHORITY + tail
  }
  const base = "You have reached your turn/budget limit and cannot call any more tools. "
    + "Provide your final answer now: briefly summarize what you accomplished and what you "
    + "verified. Do not emit any tool call."
  return base + tail
}

/**
 * True when the host opted into a product-supplied terminal wrap-up by passing a
 * non-empty `opts.wrapup_directive`. Gates the trailing-message rendering path,
 * the structured-context block, and the tools==0 eligibility broadening.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_has_host_wrapup_directive(opts) {
  return trim(to_string(opts?.wrapup_directive ?? "")) != ""
}

/**
 * The effective wrap-up context: the host's structured `opts.wrapup_context`
 * (product facts such as files touched with line counts, verification receipts,
 * veto context) with the loop's own terminal facts merged on top so they are
 * AUTHORITATIVE — a host context can never override the real terminal kind or
 * stop reason (the outcome-authority contract). Terminal facts are always
 * present on the host path even when the host supplies no context, so the model
 * always learns which terminal it is narrating.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_effective_wrapup_context(opts, final_status, stop_reason) {
  const host_context = if type_of(opts?.wrapup_context) == "dict" {
    opts.wrapup_context
  } else {
    {}
  }
  return host_context
    + {
    terminal_kind: final_status,
    final_status: final_status,
    stop_reason: stop_reason ?? "",
  }
}

/**
 * Render an effective wrap-up context dict into a compact, labeled,
 * language-generic facts block appended after the directive in the trailing
 * wrap-up message. The loop stays agnostic to the host's key names — it emits
 * the dict as deterministic JSON so any host's terminal facts reach the model
 * verbatim while the host's directive explains how to read them. Returns "" only
 * when serialization fails.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_render_wrapup_context(context) {
  if context == nil || type_of(context) != "dict" || len(keys(context)) == 0 {
    return ""
  }
  const rendered = try {
    json_stringify(context)
  } catch (e) {
    ""
  }
  if trim(to_string(rendered)) == "" {
    return ""
  }
  return "\n\nTerminal context (authoritative facts):\n" + to_string(rendered)
}

/**
 * True when the loop's terminal status is an exhaustion/cap (not a clean
 * `done`, a suspension, a scope alert, or a hard provider/terminal error).
 * These are the states where the model never produced a clean terminal
 * response and a wrap-up turn is warranted.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_wrapup_eligible_status(final_status) {
  return final_status == "budget_exhausted"
    || final_status == "verify_capped"
    || final_status == "verify_exhausted"
    || final_status == "stuck"
}

pub fn __agent_loop_terminal_callback_continue_allowed(final_status, stop_reason) {
  if final_status == "stuck" {
    return true
  }
  return final_status == "budget_exhausted" && (stop_reason ?? "") == "max_iterations"
}

pub fn __agent_loop_terminal_callback_extend_by(budget) {
  const by = budget?.extend_by ?? 0
  if type_of(by) == "int" && by > 0 {
    return by
  }
  return 1
}

pub fn __agent_loop_tool_call_names(tool_calls) {
  let names = []
  for call in tool_calls ?? [] {
    const name = __tool_call_name(call)
    if name != "" && !contains(names, name) {
      names = names.push(name)
    }
  }
  return names
}

pub fn __agent_loop_repeated_observation_recovery_feedback(warning) {
  const tool_name = to_string(warning?.tool_name ?? "")
  const action = if tool_name != "" {
    "`" + tool_name + "`"
  } else {
    "the same tool"
  }
  const signature = trim(to_string(warning?.signature ?? ""))
  const signature_line = if signature != "" {
    "\nRepeated call signature: " + signature
  } else {
    ""
  }
  return "Repeated identical " + action + " calls are not making progress. "
    + "Do not issue that same call again. Change strategy with a targeted action "
    + "that changes evidence, such as reading the exact file or symbol named by "
    + "the task, then edit only once you have the needed facts."
    + signature_line
}

pub fn __agent_loop_monologue_recovery_feedback(visible_text) {
  let intent = trim(to_string(visible_text ?? ""))
  if len(intent) > 900 {
    intent = intent[0:900] + "..."
  }
  const intent_block = if intent != "" {
    "\nYour stated intent was:\n" + intent
  } else {
    ""
  }
  return "You are describing the next action instead of taking it. On the next turn, "
    + "emit exactly one concrete tool call as the first action; do not continue in prose. "
    + "Use the available tool that matches your stated intent, with explicit arguments."
    + intent_block
}

pub fn __agent_loop_thrash_recovery_allowed(warning, tool_calls) {
  if !(warning?.hard_stop ?? false) {
    return false
  }
  const pattern = to_string(warning?.pattern ?? "")
  if pattern == "repeated_same_observation" {
    return len(tool_calls ?? []) > 0
  }
  return pattern == "no_progress_monologue" && len(tool_calls ?? []) == 0
}

pub fn __agent_loop_thrash_recovery_reason(warning) -> string {
  if to_string(warning?.pattern ?? "") == "no_progress_monologue" {
    return "monologue_recovery_continue"
  }
  return "thrash_recovery_continue"
}

pub fn __agent_loop_thrash_recovery_feedback(warning, visible_text) -> string {
  if to_string(warning?.pattern ?? "") == "no_progress_monologue" {
    return __agent_loop_monologue_recovery_feedback(visible_text)
  }
  return __agent_loop_repeated_observation_recovery_feedback(warning)
}

pub fn __agent_loop_thrash_recovery_key(warning) -> string {
  if to_string(warning?.pattern ?? "") == "no_progress_monologue" {
    return "monologue_recovery"
  }
  return "thrash_recovery"
}

pub fn __agent_loop_thrash_recovery_skip_reason(warning) -> string {
  if to_string(warning?.pattern ?? "") == "no_progress_monologue" {
    return "monologue_recovery"
  }
  return "thrash_recovery"
}

pub fn __agent_loop_thrash_recovery_skip_outcome(warning, tool_calls, stall_hard_stop_rescues) {
  const pattern = to_string(warning?.pattern ?? "")
  if pattern != "repeated_same_observation" && pattern != "no_progress_monologue" {
    return "no_rung_for_pattern"
  }
  if pattern == "repeated_same_observation" && len(tool_calls ?? []) == 0 {
    return "no_tool_calls"
  }
  if pattern == "no_progress_monologue" && len(tool_calls ?? []) > 0 {
    return "unexpected_tool_calls"
  }
  if stall_hard_stop_rescues > 0 {
    return "rung_limit_exhausted"
  }
  return "recovery_not_allowed"
}

pub fn __agent_loop_stall_warning_receipt(warning) {
  return {
    pattern: to_string(warning?.pattern ?? ""),
    tool_name: to_string(warning?.tool_name ?? ""),
    repeat_count: to_int(warning?.repeat_count ?? warning?.count ?? 0) ?? 0,
    threshold: to_int(warning?.threshold ?? 0) ?? 0,
    consecutive_trips: to_int(warning?.consecutive_trips ?? 0) ?? 0,
    hard_stop: warning?.hard_stop ?? false,
    arguments_digest: to_string(warning?.arguments_digest ?? ""),
    signature_digest: to_string(warning?.signature_digest ?? ""),
    signature: to_string(warning?.signature ?? ""),
  }
}

pub fn __agent_loop_emit_thrash_recovery_checkpoint(
  session_id,
  iteration,
  tool_calls,
  warning,
  old_limit,
  new_limit,
  status = "fired",
  action = "extend",
  reason = "thrash_recovery_continue",
  outcome = nil,
) {
  let checkpoint = {
    schema: "harn.agent_loop_recovery_receipt.v1",
    kind: "thrash_hard_stop_recovery",
    receipt_kind: "thrash_hard_stop_recovery",
    status: status,
    action: action,
    reason: reason,
    iteration: iteration,
    old_limit: old_limit,
    new_limit: new_limit,
    tool_count: len(tool_calls ?? []),
    tool_names: __agent_loop_tool_call_names(tool_calls),
    warning: __agent_loop_stall_warning_receipt(warning),
  }
  if outcome != nil {
    checkpoint = checkpoint + {outcome: outcome}
  }
  agent_emit_event(session_id, "typed_checkpoint", checkpoint)
}

/**
 * Forced terminal wrap-up turn. When the loop exits on exhaustion/cap while the
 * last turn still called tools, the surfaced final text would otherwise be a
 * dangling tool-call turn with no clean `<user_response>`/sentinel. This runs
 * exactly one extra LLM call with tools requested off to elicit the model's
 * final user-facing answer + completion sentinel, records it as the final
 * assistant turn so `agent_session_finalize` surfaces it, and never mutates
 * `final_status`/`stop_reason`. Provider/tool-format leaks can still yield
 * tool calls on this turn, so the loop records explicit skipped receipts before
 * falling back. Defensive: any error falls back to existing behavior. Returns
 * true when a wrap-up response was recorded.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_final_wrapup(message, session, opts, final_status, stop_reason, iteration) {
  const directive = __agent_loop_wrapup_directive(opts)
  // Disable tools for this turn so the model cannot dispatch: empty tool
  // surface + a system directive forbidding tool calls. Carry the tool format
  // forward so the response protocol still matches what the model was trained
  // on this run.
  const wrapup_opts = opts
    + {
    tools: nil,
    active_skills: nil,
    skill_catalog_prompt: "",
    _progress_tool_system_prompt_nudge: "",
    transcript_projection: opts?.transcript_projection,
  }
  const turn_prompt = __agent_loop_build_turn_prompt(session, wrapup_opts, iteration)
  const host_wrapup = __agent_loop_has_host_wrapup_directive(wrapup_opts)
  // System prompt + message array. A host-supplied directive is delivered as a
  // trailing user message and the system prompt is left byte-identical to the
  // loop's turns, so the whole system+history prefix is reused verbatim by the
  // provider prompt cache / local KV cache — the wrap-up pays only for the one
  // appended instruction. The default (no host directive) keeps today's exact
  // behavior: the generic directive rides in the system prompt.
  const base_system = if trim(turn_prompt.system ?? "") != "" {
    turn_prompt.system
  } else {
    ""
  }
  const turn_system = if host_wrapup {
    base_system
  } else {
    let parts = []
    if base_system != "" {
      parts = parts.push(base_system)
    }
    parts = parts.push(directive)
    join(parts, "\n\n")
  }
  const wrapup_context = __agent_loop_effective_wrapup_context(
    wrapup_opts,
    final_status,
    stop_reason,
  )
  const turn_messages = if host_wrapup {
    turn_prompt.messages.push(
      {role: "user", content: directive + __agent_loop_render_wrapup_context(wrapup_context)},
    )
  } else {
    turn_prompt.messages
  }
  const llm_opts = __agent_loop_effective_llm_options(wrapup_opts)
    + {
    messages: turn_messages,
    session_id: session.session_id,
    tool_format: wrapup_opts?.tool_format,
    tools: nil,
    _iteration: iteration + 1,
    _final_wrapup: true,
  }
  let call = agent_invoke_llm(message, turn_system, llm_opts)
  if !call.ok {
    return false
  }
  const llm_result = call.value
  const raw_text = llm_result?.raw_text ?? llm_result?.text ?? ""
  const parsed = agent_parse_tool_calls(raw_text, nil, wrapup_opts?.tool_format)
  const wrapup_tool_calls = __resolve_tool_calls(llm_result, parsed)
  if len(wrapup_tool_calls) > 0 {
    agent_session_record_undispatched_tool_results(
      session.session_id,
      wrapup_tool_calls,
      "skipped",
      "not dispatched: final wrap-up turns cannot call tools",
    )
    agent_emit_event(
      session.session_id,
      "typed_checkpoint",
      {
        schema: "harn.agent_loop_final_wrapup_receipt.v1",
        kind: "final_wrapup_tool_calls_skipped",
        receipt_kind: "final_wrapup_tool_calls_skipped",
        status: "skipped",
        final_status: final_status,
        stop_reason: stop_reason ?? "",
        iteration: iteration,
        tool_count: len(wrapup_tool_calls),
        tool_names: __agent_loop_tool_call_names(wrapup_tool_calls),
        host_directive: host_wrapup,
        terminal_kind: final_status,
      },
    )
  }
  const visible_text = __visible_text(parsed, raw_text)
  if to_string(visible_text) == "" {
    return false
  }
  const normalized = llm_result + {text: visible_text, visible_text: visible_text}
  agent_session_record_assistant(session.session_id, normalized)
  let _ = try {
    agent_emit_event(
      session.session_id,
      "final_wrapup",
      {
        final_status: final_status,
        stop_reason: stop_reason ?? "",
        iteration: iteration,
        host_directive: host_wrapup,
        terminal_kind: final_status,
      },
    )
  }
  return true
}

/**
 * Flatten the MCP server specs declared by the currently active skills.
 *
 * Drives the per-turn mid-conversation MCP mount (gated by the default-off
 * `mid_conversation_mcp_mount` opt): the loop feeds the result to
 * `agent_mcp_mount_additional`, which mounts only the servers not already
 * active so a mid-conversation skill's tools become visible AND callable
 * without re-mounting live servers. Empty when no active skill declares MCP
 * servers, which keeps the flag-off path untouched.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_active_skill_mcp_specs(active_skills) {
  let specs = []
  for active_skill in active_skills ?? [] {
    for spec in active_skill?.mcp ?? [] {
      specs = specs + [spec]
    }
  }
  return specs
}

/**
 * Validate `text` against `schema` (a JSON Schema dict, the same shape
 * `output_schema` carries elsewhere). Reuses the language's own schema
 * validator — `json_extract` (tolerant text→JSON) + `schema_is`
 * (canonicalize + validate, the machinery `llm_call_structured` also uses) —
 * so validation is never reimplemented here. Returns `{ok, value}`: `ok`
 * true only when the extracted value validates; `value` is the best parsed
 * value (nil when nothing parseable was found).
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_output_schema_value(text, schema) {
  const parsed = try {
    json_extract(text)
  }
  if is_err(parsed) {
    return {ok: false, value: nil}
  }
  const value = unwrap(parsed)
  const valid = try {
    schema_is(value, schema)
  }
  if is_err(valid) || !unwrap(valid) {
    return {ok: false, value: value}
  }
  return {ok: true, value: value}
}

pub fn __agent_loop_output_schema_repair_prompt(schema, text) {
  return "Your previous final answer did not satisfy the required output schema.\n"
    + "Reply with ONLY a JSON value that validates against this JSON Schema:\n\n"
    + json_stringify_pretty(
    schema,
  )
    + "\n\nYour previous final answer was:\n\n"
    + text
}

/**
 * Terminal output-schema gate for `agent_loop`.
 *
 * `output_schema` promises the loop's FINAL answer parses against a schema.
 * The loop deliberately does NOT force every mid-loop turn into structured
 * mode (that fights tool-calling; see `__agent_loop_effective_llm_options`).
 * Instead the terminal answer is validated here. If it already satisfies the
 * schema it is surfaced as-is; otherwise the loop re-asks ONCE — through the
 * same `llm_caller` seam ordinary turns use, so custom callers and test mocks
 * intercept it — for a schema-shaped value, mirroring the repair-once
 * behavior `llm_call_structured` implements. The parsed value lands on
 * `run.output`; `run.output_valid` records whether validation ultimately
 * passed (never throws — a persistently off-shape answer yields
 * `output_valid: false`, not a crash). When the repair turn produces the
 * terminal answer, surface that repaired text so `visible_text` and `output`
 * describe the same final payload.
 *
 * @effects: [agent]
 * @errors: []
 */
pub fn __agent_loop_apply_output_schema(final_result, opts, session, message, iteration) {
  const schema = opts?.output_schema
  let text = to_string(final_result?.visible_text ?? final_result?.text ?? "")
  const direct = __agent_loop_output_schema_value(text, schema)
  if direct.ok {
    return final_result + {output: direct.value, output_valid: true}
  }
  const repair_opts = __agent_loop_effective_llm_options(opts + {_output_schema_repair: true})
    + {
    session_id: session.session_id,
    tools: nil,
    tool_choice: nil,
    tool_format: opts?.tool_format,
    _iteration: iteration + 1,
    _output_schema_repair: true,
  }
  const repair = try {
    agent_invoke_llm(__agent_loop_output_schema_repair_prompt(schema, text), nil, repair_opts)
  }
  if is_err(repair) {
    return final_result + {output: direct.value, output_valid: false}
  }
  let call = unwrap(repair)
  if !(call?.ok ?? false) {
    return final_result + {output: direct.value, output_valid: false}
  }
  const repaired_text = to_string(call?.value?.visible_text ?? call?.value?.text ?? "")
  const second = __agent_loop_output_schema_value(repaired_text, schema)
  return final_result
    + {
    text: repaired_text,
    visible_text: repaired_text,
    output: second.value,
    output_valid: second.ok,
  }
}