harn-stdlib 0.10.104

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
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
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
import { agent_done_judge_config, agent_has_done_judge } from "std/agent/judge"
import "std/agent/loop_result_status"
import { agent_render_tool_call_exemplar, agent_tool_call_paradigm } from "std/agent/preflight"
import { agent_parse_tool_calls } from "std/agent/primitives"
import { agent_required_tools_missing_from_session } from "std/agent/required_tools"
import {
  agent_emit_event,
  agent_session_apply_reminder_post_turn,
  agent_session_inject_feedback,
} from "std/agent/state"
import {
  __tool_surface_attempted_names,
  __tool_surface_current_names,
  __tool_surface_narrowing_post_turn,
} from "std/agent/tool_surface"
import { tool_fence_info_opens_call } from "std/llm/dialects"
import { missing_tool_call_classifier } from "std/llm/missing_tool_call"
import { tool_required_argument_exemplar } from "std/llm/structural_validator"

fn __default_done_sentinel(opts) {
  return opts?.done_sentinel
}

fn __sentinel_hit(text, sentinel, parsed_done_marker) {
  if sentinel == nil {
    return false
  }
  if parsed_done_marker != nil && parsed_done_marker != "" {
    return contains(parsed_done_marker, sentinel)
  }
  return contains(text, sentinel)
}

fn __post_turn_callback_verdict(opts, payload) {
  const cb = opts?.post_turn_callback
  if cb == nil {
    return {kind: "none"}
  }
  const verdict = cb(payload)
  return __interpret_verdict(verdict)
}

fn __callback_feedback_kind(verdict) -> string? {
  const feedback_kind = verdict?.feedback_kind
  if feedback_kind == nil {
    return nil
  }
  if type_of(feedback_kind) != "string" {
    throw "agent_loop: callback verdict `feedback_kind` must be a string or nil; got "
      + type_of(
      feedback_kind,
    )
  }
  const normalized = trim(feedback_kind)
  if normalized == "" {
    return nil
  }
  return normalized
}

fn __interpret_verdict(verdict) {
  if verdict == nil || verdict == "" {
    return {kind: "none"}
  }
  if type_of(verdict) == "bool" {
    return if verdict {
      {kind: "stop"}
    } else {
      {kind: "none"}
    }
  }
  if type_of(verdict) == "string" {
    return {kind: "inject", message: verdict}
  }
  if type_of(verdict) == "dict" {
    const stop = verdict?.stop ?? false
    const base_llm_options = if type_of(verdict?.llm_options) == "dict" {
      verdict.llm_options
    } else {
      {}
    }
    const llm_options = if verdict?.prefill != nil && verdict?.prefill != "" {
      base_llm_options + {prefill: verdict?.prefill}
    } else {
      verdict?.llm_options
    }
    return {
      kind: "rich",
      stop: stop,
      stop_reason: verdict?.stop_reason,
      needs_verify: verdict?.needs_verify,
      message: verdict?.message,
      feedback_kind: __callback_feedback_kind(verdict),
      next_options: verdict?.next_options,
      llm_options: llm_options,
    }
  }
  return {kind: "none"}
}

fn __post_turn_stop_needs_verify(verdict) {
  if verdict?.needs_verify == nil {
    return false
  }
  return verdict.needs_verify ? true : false
}

fn __terminal_callback_verdict(opts, payload) {
  const cb = opts?.terminal_callback
  if cb == nil {
    return {kind: "none"}
  }
  const verdict = cb(payload)
  return __interpret_verdict(verdict)
}

fn __successful_tool_names(dispatch) {
  return __tool_names_by_status(dispatch, true)
}

fn __rejected_tool_names(dispatch) {
  return __tool_names_by_status(dispatch, false)
}

fn __stop_after_successful_tools(opts, dispatch) {
  const names = opts?.stop_after_successful_tools
  if names == nil || len(names) == 0 {
    return false
  }
  const successful = __successful_tool_names(dispatch)
  for required in names {
    if contains(successful, required) {
      return true
    }
  }
  return false
}

/**
 * The classic gate for "prose without tool_calls = natural completion":
 * loop_until_done with native tools, no explicit completion signal, and
 * non-empty visible text without tool_calls. Shared between the
 * completion check and the nudge-injection check so they cannot drift.
 */
fn __native_completion_context(opts, has_tool_calls, text) {
  if !(opts?.loop_until_done ?? false) || opts?.daemon {
    return false
  }
  if opts?.tool_format != "native" || opts?.tools == nil {
    return false
  }
  if __default_done_sentinel(opts) != nil {
    return false
  }
  if has_tool_calls {
    return false
  }
  return trim(text) != ""
}

/**
 * Narrows `__native_completion_context` to the regression-prone case:
 * the model is "done" but has emitted ZERO tool_calls so far this
 * session (successful OR rejected — a failed tool call still shows
 * the model engaged the tool channel). This is the documented Qwen3
 * thinking + native tools failure signature:
 * reasoning trace narrates a tool action, but
 * the structured tool_calls channel emits nothing at all. Once the
 * model has used the tool channel even once (even unsuccessfully),
 * subsequent prose-only turns are legitimate final-answer signals
 * and the classic behavior applies.
 */
fn __zero_tool_completion_context(opts, has_tool_calls, text) {
  if !__native_completion_context(opts, has_tool_calls, text) {
    return false
  }
  const successful = len(opts?._session_successful_tools ?? [])
  const rejected = len(opts?._session_rejected_tools ?? [])
  return successful + rejected == 0
}

/**
 * In the classic native-completion context, prose-without-tool_calls
 * means "I'm done." The one regression-prone subcase is the
 * zero-tools-yet case, where we require ONE confirmation nudge before
 * accepting prose as completion: `__consecutive_text_only` must be >= 2
 * (i.e. the model emitted prose, got the nudge, then emitted prose
 * again). After the nudge the model either recovers (calls a tool,
 * resetting the counter) or confirms (raising it to 2, which we
 * accept). The outer loop's `max_nudges` budget still caps the loop.
 */
fn __native_tool_text_completion(opts, has_tool_calls, text) {
  if !__native_completion_context(opts, has_tool_calls, text) {
    return false
  }
  // #3220: a zero-dispatch turn that ATTEMPTED a tool call as text is a
  // failed call, not a completion signal. The loop already queued the
  // corrective feedback this turn (the native-fallback contract note, or
  // parse guidance for markup the parser could not promote into a call), so
  // give the model the next turn instead of reading the prose as "done".
  // Live failure: in a native session qwen3.6 emitted a `<function=edit>`
  // call as plain text on its final turn; the native path ignored the text,
  // the turn read as a natural completion, and the run ended with the edit
  // lost. An explicit done sentinel still wins (it is an intentional host
  // contract); this only stops the implicit prose-means-done read.
  if opts?._turn_tool_call_feedback ?? false {
    return false
  }
  if !__zero_tool_completion_context(opts, has_tool_calls, text) {
    return true
  }
  return (opts?._consecutive_text_only ?? 1) >= 2
}

/**
 * Inject the one-shot completion-confirmation nudge when we're in the
 * regression-prone subcase (zero tools called, prose-only) and haven't
 * already nudged this session. Keyed on `_consecutive_text_only` so it
 * is idempotent under repeated post-turn calls.
 */
fn __maybe_inject_completion_confirmation(
  agent: HarnessAgent,
  session,
  opts,
  has_tool_calls,
  text,
  iteration,
) {
  if !__zero_tool_completion_context(opts, has_tool_calls, text) {
    return false
  }
  if (opts?._consecutive_text_only ?? 1) >= 2 {
    return false
  }
  agent_session_inject_feedback(
    agent,
    session.session_id,
    "completion_confirmation",
    "You ended your turn without calling a tool. If you intended to call a tool, call it now. If this was your final answer, restate it briefly on the next line — do not describe your process.",
  )
  agent_emit_event(
    agent,
    session.session_id,
    "completion_confirmation_nudge",
    {iteration: iteration, visible_text_prefix: visible_text_excerpt(text)},
  )
  return true
}

fn __required_tools_text_completion(opts, has_tool_calls, text) {
  if has_tool_calls || trim(text) == "" || opts?.require_successful_tools == nil {
    return false
  }
  const successful = opts?._session_successful_tools ?? []
  return len(agent_required_tools_missing_from_session(opts, successful)) == 0
}

/**
 * A turn is "toolless" in a TEXT-FAMILY format (`text` or `json`) when the
 * model dispatched no tools this turn, the loop is in one of the text-based
 * tool-formats, and there is visible prose to inspect. Unlike
 * `__native_completion_context`, this fires for the case the native gate
 * excludes. Used to power the fenced-call and missing-tool-call recovery below.
 * Both text-based lanes emit their calls in assistant text and are parsed by a
 * text parser, so both need the same toolless recovery; the corrective wording
 * renders per active tool_format from `agent_tool_call_paradigm`.
 */
fn __text_family_toolless_turn(opts, has_tool_calls, text) {
  if opts?.daemon {
    return false
  }
  const fmt = lowercase(to_string(opts?.tool_format ?? ""))
  if fmt != "text" && fmt != "json" {
    return false
  }
  if has_tool_calls {
    return false
  }
  return trim(text ?? "") != ""
}

/**
 * Detect a fenced tool-call attempt: the model wrote the action inside a
 * Markdown fence (```tool_code / ```call / ```edit / ```python / ```bash /
 * ```run) instead of a bare `<tool_call>` block, so the parser saw no
 * call. Generalizable — conditioned on the observed fence opener, not on
 * any model name. Returns the offending fence language or nil.
 */
fn __detect_fenced_call_attempt(text) {
  const lowered = lowercase(to_string(text ?? ""))
  // Ask the dialect vocabulary before the heuristics below. A fence carrying
  // the label the model was TAUGHT to use is the likeliest attempt of all, and
  // a hand-maintained list is exactly what let it fall through: `tool` was
  // absent here while the prompt instructed it, so a turn full of well-formed
  // calls produced no call and no nudge.
  for line in split(lowered, "\n") {
    const trimmed = trim(line)
    if starts_with(trimmed, "```") {
      const info = trim(trimmed[3:len(trimmed)])
      if info != "" && tool_fence_info_opens_call(info) {
        return info
      }
    }
  }
  const openers = [
    "```tool_code",
    "```call",
    "```edit",
    "```python",
    "```bash",
    "```run",
    "```shell",
  ]
  for opener in openers {
    if contains(lowered, opener) {
      return opener[3:len(opener)]
    }
  }
  return nil
}

/**
 * Extract the bound tool names from the agent `tools` registry value.
 * The registry carries a `.tools` list of entries each with a `.name`.
 */
fn __agent_bound_tool_names(tools) {
  const entries = tools?.tools ?? []
  let names = []
  for entry in entries {
    const name = entry?.name ?? ""
    if name != "" {
      names = names.appending(name)
    }
  }
  return names
}

/**
 * Does the text already carry a real tool-call shape? Accepts BOTH the brace
 * object-literal form `name({ ... })` and the braceless `name(key=...)` /
 * `name(key: ...)` form so missing-call recovery does not run on a turn that
 * already contains call syntax.
 */
fn __text_has_call_shape(text) {
  const body = to_string(text ?? "")
  if regex_match("[A-Za-z_][A-Za-z0-9_]*\\s*\\(\\s*\\{", body) != nil {
    return true
  }
  if regex_match("[A-Za-z_][A-Za-z0-9_]*\\s*\\(\\s*[A-Za-z_][A-Za-z0-9_]*\\s*[=:]", body) != nil {
    return true
  }
  return false
}

fn __missing_tool_call_recovery_config(opts) {
  const raw = opts?.missing_tool_call_recovery
  if type_of(raw) == "bool" && !raw {
    return {enabled: false, options: {}}
  }
  let options = if type_of(raw) == "dict" {
    raw
  } else {
    {}
  }
  if options?.provider == nil && opts?.provider != nil {
    options = options + {provider: opts.provider}
  }
  if options?.model == nil && opts?.model != nil {
    options = options + {model: opts.model}
  }
  if type_of(raw) == "dict" {
    return {enabled: raw?.enabled ?? true, options: options}
  }
  return {enabled: true, options: options}
}

fn __missing_tool_call_recovery_classifier(agent: HarnessAgent, llm: HarnessLlm, opts) {
  const recovery = __missing_tool_call_recovery_config(opts)
  if !recovery.enabled {
    return nil
  }
  const options = recovery.options
  const has_test_classifier = type_of(options?.classifier) == "closure"
  if lowercase(to_string(opts?.provider ?? "")) == "mock" && !has_test_classifier {
    return nil
  }
  if opts?._llm_caller != nil && !has_test_classifier {
    return nil
  }
  return missing_tool_call_classifier(agent, llm, options)
}

fn __tool_name_in_list(name: string, names: list<string>) -> bool {
  for candidate in names {
    if name == candidate {
      return true
    }
  }
  return false
}

/**
 * Classify "intended tool action, no parsed call" through the default typed
 * recovery path. Classifier failures fail open to no nudge.
 */
fn __classify_missing_tool_call(
  agent: HarnessAgent,
  llm: HarnessLlm,
  session,
  text,
  opts,
  iteration,
) {
  if __text_has_call_shape(text) {
    return nil
  }
  const classifier = __missing_tool_call_recovery_classifier(agent, llm, opts)
  if classifier == nil {
    return nil
  }
  const tools = opts?.tools
  if tools == nil {
    return nil
  }
  const names = __agent_bound_tool_names(tools)
  if len(names) == 0 {
    return nil
  }
  const verdict = try {
    classifier(
      {
        session_id: session.session_id,
        iteration: iteration,
        text: text,
        visible_text: text,
        tools: tools,
        tool_names: names,
        tool_format: opts?.tool_format,
      },
    )
  }
  if is_err(verdict) {
    const err = unwrap_err(verdict)
    if error_category(err) == "cancelled" {
      throw err
    }
    return nil
  }
  const result = unwrap(verdict)
  if type_of(result) != "dict" {
    return nil
  }
  agent_emit_event(
    agent,
    session.session_id,
    "missing_tool_call_verdict",
    result
      + {iteration: iteration, visible_text_prefix: visible_text_excerpt(text)},
  )
  const tool_name = result?.tool_name ?? ""
  if result?.action == "tool_call_intended" && tool_name != ""
    && __tool_name_in_list(
    tool_name,
    names,
  ) {
    return result
  }
  return nil
}

/**
 * Corrective nudges for a toolless turn. Text mode also detects fenced calls;
 * both text and native modes use the same typed missing-tool-call classifier.
 * Native callers invoke this before accepting prose as natural completion.
 * Two cases, in priority order:
 *   1. fenced-call attempt → tell the model to emit the call bare.
 *   2. missing-tool-call recovery → tell the model to emit the intended call.
 * One nudge per turn; both are bounded by the loop's `max_nudges` budget.
 * Returns `true` if a content-specific nudge was injected this turn, so the
 * loop can suppress the fallback progress-streak nudge and avoid
 * double-injecting on the same turn.
 *
 * `classifier_only` drops case 1 and keeps case 2. Callers pass it on the turn
 * shape that is ALSO a completion candidate, where the question is not "what
 * kind of stall is this?" but "is this a stall at all?". Only the classifier
 * answers that with evidence; the fence detector's opener list is a heuristic
 * that cannot tell a botched call from an illustrative code block in a genuine
 * final answer, and treating the latter as a stall would talk over a finished
 * run. A real fenced attempt is not lost — the classifier reads the same text
 * and answers it with the concrete exemplar, which is the better corrective.
 */
/**
 * A closing call marker left in the text with no call parsed from the turn.
 *
 * This is a different failure from an absent call: the model REACHED for the
 * emission and its markup was rejected, so answering "you did not call it"
 * describes the turn wrongly and teaches nothing about what went wrong.
 * Observed live as prose ending in `</tool>` on a lane whose grammar is the
 * ```tool block — a drifted spelling of the tagged form.
 *
 * Deliberately strict, because a false positive here would re-open an honest
 * finished turn: the marker must be the whole of a line, or end the message.
 * A marker quoted mid-sentence, or carried inside a call's own arguments, does
 * not count. Returns the offending marker, or nil.
 */
fn __closing_tool_marker(candidate) -> string? {
  const line = trim(to_string(candidate ?? ""))
  if !starts_with(line, "</") || !ends_with(line, ">") {
    return nil
  }
  const inner = lowercase(trim(substring(line, 2, len(line) - 1)))
  if !starts_with(inner, "tool") {
    return nil
  }
  return line
}

// True when the text also carries the opening half of `marker`. A complete
// block whose contents the parser rejected is a parse rejection, and that path
// already answers the turn — claiming it here would put two owners on one turn,
// which is the contradiction that stalls a loop.
fn __has_opening_tool_marker(text, marker) -> bool {
  const inner = trim(substring(marker, 2, len(marker) - 1))
  if inner == "" {
    return false
  }
  return contains(lowercase(to_string(text)), "<" + lowercase(inner))
}

fn __detect_malformed_call_markup(text) -> string? {
  const trimmed = trim(to_string(text ?? ""))
  if trimmed == "" {
    return nil
  }
  const dangling = __dangling_tool_marker(trimmed)
  if dangling == nil || __has_opening_tool_marker(trimmed, dangling) {
    return nil
  }
  return dangling
}

fn __dangling_tool_marker(trimmed) -> string? {
  const lines = split(trimmed, "\n")
  // A marker occupying a whole line is unambiguous.
  for line in lines {
    const whole = __closing_tool_marker(line)
    if whole != nil {
      return whole
    }
  }
  // Otherwise only the very end of the message counts. A marker quoted
  // mid-message is prose discussing the syntax, not an attempt at it.
  const last = trim(lines[len(lines) - 1])
  if !ends_with(last, ">") {
    return nil
  }
  const pieces = split(last, "</")
  if len(pieces) < 2 {
    return nil
  }
  return __closing_tool_marker("</" + pieces[len(pieces) - 1])
}

fn __maybe_inject_action_nudge(
  agent: HarnessAgent,
  llm: HarnessLlm,
  session,
  opts,
  has_tool_calls,
  text,
  iteration,
  classifier_only = false,
) {
  const text_family = __text_family_toolless_turn(opts, has_tool_calls, text)
  const native_mode = __native_completion_context(opts, has_tool_calls, text)
  if !text_family && !native_mode {
    return false
  }
  // The corrective wording renders the taught call syntax from the single
  // format-aware owner, so a `json` lane is told to emit a ```tool block and a
  // `text` lane a `<tool_call>` block — never the wrong grammar.
  const paradigm = agent_tool_call_paradigm(llm, opts)
  const fenced = if text_family && !classifier_only {
    __detect_fenced_call_attempt(text)
  } else {
    nil
  }
  if fenced != nil {
    agent_session_inject_feedback(
      agent,
      session.session_id,
      "fenced_call_attempt",
      "You wrapped a tool call in a ```"
        + fenced
        + " fence — that is ignored. Emit the call as a "
        + paradigm.call_noun
        + " instead.",
    )
    agent_emit_event(
      agent,
      session.session_id,
      "fenced_call_attempt_nudge",
      {iteration: iteration, fence: fenced, visible_text_prefix: visible_text_excerpt(text)},
    )
    return true
  }
  // An attempted-but-rejected emission is diagnosable from the text alone, so
  // answer it before spending a classifier call on the question "did this turn
  // intend a call" — the dangling marker already answered that.
  const malformed = if text_family {
    __detect_malformed_call_markup(text)
  } else {
    nil
  }
  if malformed != nil {
    agent_session_inject_feedback(
      agent,
      session.session_id,
      "malformed_call_markup",
      "Your call markup was malformed: the message ended with `"
        + malformed
        + "` but no call could be read from it. Emit the call as a "
        + paradigm.call_noun
        + " instead.",
    )
    agent_emit_event(
      agent,
      session.session_id,
      "malformed_call_markup_nudge",
      {iteration: iteration, marker: malformed, visible_text_prefix: visible_text_excerpt(text)},
    )
    return true
  }
  const missing = __classify_missing_tool_call(agent, llm, session, text, opts, iteration)
  if missing != nil {
    const tool_name = missing.tool_name
    const instruction = if native_mode {
      "Call `"
        + to_string(tool_name)
        + "` now using the available native tool interface. Do not describe the action again."
    } else {
      // Show the call with the tool's OWN required argument names, sourced
      // from the tool entry rather than a template here. An exemplar carrying
      // an empty argument object is a complete, copy-pasteable call that is
      // also wrong, and it tells a model nothing about what the call needs.
      "Emit the call now: "
        + agent_render_tool_call_exemplar(
        llm,
        to_string(tool_name),
        tool_required_argument_exemplar(opts?.tools, tool_name),
        opts,
      )
        + " (replace each placeholder with a real value). Do not describe the action again."
    }
    agent_session_inject_feedback(
      agent,
      session.session_id,
      "missing_tool_call",
      "You appeared to intend a call to `"
        + to_string(tool_name)
        + "` but did not call it. "
        + instruction,
    )
    agent_emit_event(
      agent,
      session.session_id,
      "missing_tool_call_nudge",
      {
        iteration: iteration,
        tool: tool_name,
        classifier_action: missing?.action,
        classifier_confidence: missing?.confidence,
        classifier_kind: missing?.classifier_kind,
        visible_text_prefix: visible_text_excerpt(text),
      },
    )
    return true
  }
  return false
}

/**
 * The post-turn result for a turn the content-specific corrective claimed.
 *
 * `turn_claimed_for_repair` is a narrower fact than `nudged_this_turn`, which
 * any nudge sets: this one says the loop has asked for one specific repair and
 * expects the next turn to deliver it. Loop control and host control policies
 * both read it, so a treatment that would cut or redirect a churning turn can
 * see the outstanding claim rather than race it.
 */
fn __repair_claimed_result() -> dict {
  return {
    kind: "continue",
    done_judge_due: false,
    nudged_this_turn: true,
    turn_claimed_for_repair: true,
  }
}

fn __done_judge_config(opts) {
  return agent_done_judge_config(opts?.done_judge)
}

fn __done_judge_cadence(opts) {
  const judge = __done_judge_config(opts)
  if judge == nil {
    return nil
  }
  const cadence = judge?.cadence
  if type_of(cadence) == "dict" {
    return cadence
  }
  return {}
}

fn __done_judge_loop_state(opts, completion_proposed) {
  const state = opts?._done_judge_loop_state ?? {}
  const base_completion = state?.completion ?? {}
  const completion = base_completion + {proposed: completion_proposed}
  return state + {completion: completion}
}

fn __done_judge_when_due(opts, cadence, state) {
  const when = cadence?.when ?? "always"
  if type_of(when) == "closure" {
    return when(state) ? true : false
  }
  if when == "always" {
    return true
  }
  if when == "stalled" {
    const trigger = opts?._done_judge_trigger ?? ""
    const stalled = state?.stall?.triggered ?? false
    return trigger == "stalled" || stalled
  }
  return false
}

fn __done_judge_every_due(cadence, turn_number) {
  const every = cadence?.every
  if every == nil {
    return true
  }
  return turn_number % every == 0
}

fn __done_judge_past_warmup(cadence, turn_number) {
  const min_iterations = cadence?.min_iterations_before_first
  if min_iterations == nil {
    return true
  }
  return turn_number > min_iterations
}

fn __done_judge_under_cap(opts, cadence) {
  const max_invocations = cadence?.max_invocations
  if max_invocations == nil {
    return true
  }
  const invocations = opts?._done_judge_invocations ?? 0
  return invocations < max_invocations
}

fn __done_judge_due(opts, payload, completion_proposed) {
  const cadence = __done_judge_cadence(opts)
  if cadence == nil {
    return false
  }
  const raw_iteration = payload?.iteration ?? 0
  const turn_number = raw_iteration + 1
  const state = __done_judge_loop_state(opts, completion_proposed)
  return __done_judge_under_cap(opts, cadence)
    && __done_judge_past_warmup(cadence, turn_number)
    && __done_judge_every_due(cadence, turn_number)
    && __done_judge_when_due(opts, cadence, state)
}

fn __completion_result(opts, payload, stop_reason) {
  const batch_state = opts?._tool_batch_dependency_state
  const deferred = batch_state?.deferred_signatures ?? []
  if len(deferred) > 0 {
    return {kind: "continue", done_judge_due: false, pending_tool_batch_effects: true}
  }
  const verify_due = opts?.verify_completion != nil || opts?.verify_completion_judge != nil
  const done_due = __done_judge_due(opts, payload, true)
  if verify_due || done_due {
    return {kind: "break", stop_reason: stop_reason, needs_verify: true, done_judge_due: done_due}
  }
  if __done_judge_config(opts) != nil {
    return {kind: "continue", done_judge_due: false}
  }
  return {kind: "break", stop_reason: stop_reason, needs_verify: false, done_judge_due: false}
}

fn __post_turn_verdict_result(
  agent: HarnessAgent,
  session,
  _opts,
  verdict,
  feedback_kind = "post_turn",
) {
  if verdict.kind == "stop" {
    return {kind: "break", stop_reason: "post_turn_stop", needs_verify: false}
  }
  const effective_feedback_kind = verdict?.feedback_kind ?? feedback_kind
  if verdict.kind == "inject" {
    agent_session_inject_feedback(
      agent,
      session.session_id,
      effective_feedback_kind,
      verdict.message,
    )
    return {kind: "continue"}
  }
  if verdict.kind != "rich" {
    return nil
  }
  if verdict.message != nil {
    agent_session_inject_feedback(
      agent,
      session.session_id,
      effective_feedback_kind,
      verdict.message,
    )
  }
  if verdict.stop {
    const stop_reason = if verdict?.stop_reason != nil && verdict.stop_reason != "" {
      to_string(verdict.stop_reason)
    } else {
      "post_turn_stop"
    }
    return {
      kind: "break",
      stop_reason: stop_reason,
      needs_verify: __post_turn_stop_needs_verify(verdict),
      next_options: verdict?.next_options,
      llm_options: verdict?.llm_options,
    }
  }
  if verdict.message != nil || verdict?.next_options != nil || verdict?.llm_options != nil {
    return {
      kind: "continue",
      next_options: verdict?.next_options,
      llm_options: verdict?.llm_options,
    }
  }
  return nil
}

fn __post_turn_with_narrowing(outcome, narrowing) {
  if narrowing == nil {
    return outcome
  }
  return outcome + {narrowing: narrowing}
}

/**
 * agent_extract_tool_calls.
 *
 * @effects: []
 * @errors: []
 * @api_stability: experimental
 */
pub fn agent_extract_tool_calls(agent: HarnessAgent, llm_result, opts) {
  if len(llm_result?.tool_calls ?? []) > 0 {
    return llm_result.tool_calls
  }
  if opts?.tools == nil {
    return []
  }
  const parsed = agent_parse_tool_calls(
    agent,
    llm_result?.text ?? "",
    opts.tools,
    opts?.tool_format,
  )
  return parsed?.calls ?? []
}

/**
 * agent_compute_post_turn.
 *
 * @effects: [host]
 * @errors: []
 * @api_stability: experimental
 */
/**
 * Option changes a verdict carries, separated from its message.
 *
 * `next_options` and `llm_options` are parameters for the next turn, not
 * directives to the model, so they survive on a turn whose message is held.
 */
fn __verdict_option_changes(verdict) -> dict {
  let out = {}
  if verdict?.next_options != nil {
    out = out + {next_options: verdict.next_options}
  }
  if verdict?.llm_options != nil {
    out = out + {llm_options: verdict.llm_options}
  }
  return out
}

/**
 * THE arbitration point for a stalled toolless turn.
 *
 * A turn that dispatched no tools but emitted prose is the one shape several
 * owners want at once: the content-specific repair corrective, an embedder's
 * `post_turn_callback` verdict, and completion adjudication each have a
 * defensible reading of it. Deciding that by statement order is what produced
 * four separate defects; the order below is the contract instead.
 *
 *   1. An embedder verdict that STOPS the loop. Terminating is the embedder's
 *      authority and nothing may override it.
 *   2. The content-specific repair corrective. It is the only claimant that can
 *      tell a stall from a finished answer, because it fires solely on a call
 *      the model described and never emitted, and it answers with that concrete
 *      call rather than a generic directive.
 *   3. A non-stopping embedder verdict: advisory message and option changes.
 *   4. Completion adjudication.
 *
 * When 2 claims the turn, 3's advisory MESSAGE is held for that turn: two
 * owners issuing different directives in one turn is the contradiction that
 * stalls a loop, and between the two the corrective's is the actionable one.
 * Option changes still apply, per `__verdict_option_changes`. The embedder is
 * not left guessing — the claim reaches it as `prior_turn_claimed_for_repair`
 * on the next turn's payload.
 *
 * No other site may return a post-turn result for this turn shape.
 */
fn __arbitrate_stalled_toolless_turn(
  agent: HarnessAgent,
  llm: HarnessLlm,
  session,
  opts,
  payload,
  verdict,
  text,
  iteration,
) {
  const verdict_stops = verdict?.kind == "stop"
    || (verdict?.kind == "rich" && (verdict?.stop ?? false))
  if verdict_stops {
    const stop_result = __post_turn_verdict_result(agent, session, opts, verdict)
    if stop_result != nil {
      return stop_result
    }
  }
  const {loop_until_done = false, daemon = false} = opts ?? {}
  const has_judge = agent_has_done_judge(opts?.done_judge)
  // A repair claim only means something if the loop has another turn to give.
  // A single-shot loop ends ON this turn, so claiming it would replace a clean
  // completion with a dangling continue asking for a call that can never be
  // made. This is the reachability the pre-arbitration branches had by accident
  // of where they sat; here it is stated.
  const loop_continues = loop_until_done || daemon || has_judge
  // The fence detector is a text-shape heuristic. A judge-installed loop keeps
  // it out of the arbitration so an honest completion is never re-opened over
  // formatting alone; there, the classifier is the only claimant.
  const format_is_native = lowercase(to_string(opts?.tool_format ?? "")) == "native"
  const classifier_only = !format_is_native && has_judge
  const repaired = if loop_continues {
    __maybe_inject_action_nudge(agent, llm, session, opts, false, text, iteration, classifier_only)
  } else {
    false
  }
  if repaired {
    return __repair_claimed_result() + __verdict_option_changes(verdict)
  }
  const verdict_result = __post_turn_verdict_result(agent, session, opts, verdict)
  if verdict_result != nil {
    return verdict_result
  }
  // Completion reasons keep their original relative order: a judge grants
  // completion, then the native prose-means-done read, then required-tools
  // satisfaction, then the plain non-looping case. Reordering these would
  // silently change the reported `stop_reason` when two of them hold at once.
  if has_judge || __native_tool_text_completion(opts, false, text) {
    return __completion_result(opts, payload, "natural")
  }
  if __required_tools_text_completion(opts, false, text) {
    return __completion_result(opts, payload, "required_tools_satisfied")
  }
  if !loop_until_done && !daemon {
    return __completion_result(opts, payload, "natural")
  }
  const confirmation_nudged = __maybe_inject_completion_confirmation(
    agent,
    session,
    opts,
    false,
    text,
    iteration,
  )
  return {kind: "continue", done_judge_due: false, nudged_this_turn: confirmation_nudged}
}

pub fn agent_compute_post_turn(
  agent: HarnessAgent,
  llm: HarnessLlm,
  session,
  llm_result,
  dispatch,
  opts,
  iteration,
) {
  const sentinel = __default_done_sentinel(opts)
  const text = llm_result?.text ?? ""
  const sentinel_text = llm_result?.raw_text ?? text
  const parsed_done_marker = llm_result?.parsed_done_marker
  const dispatch_results = __dispatch_results_list(dispatch)
  const successful_tool_names = __successful_tool_names(dispatch)
  const rejected_tool_names = __rejected_tool_names(dispatch)
  const tool_names = __tool_surface_attempted_names(llm_result, dispatch_results)
  const has_tool_calls = len(llm_result?.tool_calls ?? []) > 0 || len(dispatch_results) > 0
  const payload = {
    session_id: session.session_id,
    session: {id: session.session_id},
    iteration: iteration,
    has_tool_calls: has_tool_calls,
    dispatch: dispatch,
    tool_results: dispatch_results,
    tool_count: len(dispatch_results),
    tool_names: tool_names,
    tools: opts?.tools,
    available_tool_names: __tool_surface_current_names(opts),
    provider: opts?.provider,
    model: opts?.model,
    tool_format: opts?.tool_format,
    successful_tool_names: successful_tool_names,
    rejected_tool_names: rejected_tool_names,
    session_successful_tools: opts?._session_successful_tools ?? successful_tool_names,
    session_rejected_tools: opts?._session_rejected_tools ?? rejected_tool_names,
    // The corrective claimed the PREVIOUS turn for repair, so this turn is the
    // repair attempt it asked for. Host control policies that classify a
    // toolless turn as churn read this to tell "the model is spinning" from
    // "the loop asked for exactly one more turn and this is it", instead of
    // inferring it from ordering they cannot see.
    prior_turn_claimed_for_repair: opts?._prior_turn_claimed_for_repair ?? false,
    text: text,
    visible_text: text,
  }
  __host_fire_session_hook("post_turn", payload)
  agent_session_apply_reminder_post_turn(agent, session.session_id, iteration)
  const narrowing = if contains(successful_tool_names, "load_skill") {
    {history: [], narrowed_tools: nil, changed: false}
  } else {
    __tool_surface_narrowing_post_turn(agent, session, llm_result, dispatch_results, opts)
  }
  // Tool attempts take precedence over a completion marker from the same
  // turn: the next model turn must see the dispatch result before completion.
  if !has_tool_calls && __sentinel_hit(sentinel_text, sentinel, parsed_done_marker) {
    return __post_turn_with_narrowing(__completion_result(opts, payload, "sentinel"), narrowing)
  }
  if __stop_after_successful_tools(opts, dispatch) {
    return __post_turn_with_narrowing(
      __completion_result(opts, payload, "natural") + {done_judge_due: false},
      narrowing,
    )
  }
  const verdict = __post_turn_callback_verdict(opts, payload)
  // Every claimant for a stalled toolless turn is arbitrated in one place, so
  // this shape must not be adjudicated anywhere below.
  if !has_tool_calls && trim(text) != "" {
    return __post_turn_with_narrowing(
      __arbitrate_stalled_toolless_turn(
        agent,
        llm,
        session,
        opts,
        payload,
        verdict,
        text,
        iteration,
      ),
      narrowing,
    )
  }
  const verdict_result = __post_turn_verdict_result(agent, session, opts, verdict)
  if verdict_result != nil {
    return __post_turn_with_narrowing(verdict_result, narrowing)
  }
  const {loop_until_done = false, daemon = false} = opts ?? {}
  if !has_tool_calls && !loop_until_done && !daemon {
    return __post_turn_with_narrowing(__completion_result(opts, payload, "natural"), narrowing)
  }
  // Only turns that dispatched tools, or emitted nothing at all, reach here —
  // the corrective's own gates require a toolless turn WITH prose, which the
  // arbiter above already owns, so there is no action nudge left to inject.
  const confirmation_nudged = __maybe_inject_completion_confirmation(
    agent,
    session,
    opts,
    has_tool_calls,
    text,
    iteration,
  )
  return __post_turn_with_narrowing(
    {kind: "continue", done_judge_due: false, nudged_this_turn: confirmation_nudged},
    narrowing,
  )
}

/**
 * agent_compute_terminal_callback gives host policy one bounded chance to
 * transform a non-successful loop terminal (for example max-iteration exhaustion
 * over a still-red workspace) into a normal continuation with `next_options`.
 * It intentionally uses a distinct `terminal_callback` option instead of
 * replaying `post_turn_callback`, so ordinary no-tool/no-write nudges do not
 * accidentally reinterpret every terminal budget exit as a normal turn.
 *
 * @effects: [host]
 * @errors: []
 */
pub fn agent_compute_terminal_callback(agent: HarnessAgent, session, opts, terminal) {
  const payload = {
    session_id: session.session_id,
    session: {id: session.session_id},
    terminal: true,
    iteration: terminal?.iteration ?? 0,
    final_status: terminal?.final_status ?? "",
    stop_reason: terminal?.stop_reason ?? "",
    budget_exhausted: (terminal?.final_status ?? "") == "budget_exhausted",
    max_iterations: terminal?.max_iterations ?? 0,
    iteration_budget: terminal?.iteration_budget,
    has_tool_calls: false,
    dispatch: {results: []},
    tool_results: [],
    tool_count: 0,
    tool_names: [],
    successful_tool_names: [],
    rejected_tool_names: [],
    session_successful_tools: terminal?.session_successful_tools ?? [],
    session_rejected_tools: terminal?.session_rejected_tools ?? [],
    text: "",
    visible_text: "",
    last_assistant_text: "",
  }
  const verdict = __terminal_callback_verdict(opts, payload)
  const verdict_result = __post_turn_verdict_result(
    agent,
    session,
    opts,
    verdict,
    "terminal_callback",
  )
  if verdict_result != nil {
    return verdict_result
  }
  return {kind: "none"}
}

fn visible_text_excerpt(text) {
  const trimmed = trim(text ?? "")
  if len(trimmed) <= 160 {
    return trimmed
  }
  return trimmed[0:160] + "…"
}