@librechat/agents 3.1.76 → 3.1.77-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/cjs/graphs/Graph.cjs +9 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hitl/askUserQuestion.cjs +67 -0
  4. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -0
  5. package/dist/cjs/hooks/HookRegistry.cjs +54 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/createToolPolicyHook.cjs +115 -0
  8. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +40 -1
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/main.cjs +29 -0
  14. package/dist/cjs/main.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +400 -42
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/tools/ToolNode.cjs +551 -55
  18. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  19. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  20. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  22. package/dist/esm/graphs/Graph.mjs +9 -0
  23. package/dist/esm/graphs/Graph.mjs.map +1 -1
  24. package/dist/esm/hitl/askUserQuestion.mjs +65 -0
  25. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -0
  26. package/dist/esm/hooks/HookRegistry.mjs +54 -0
  27. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  28. package/dist/esm/hooks/createToolPolicyHook.mjs +113 -0
  29. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -0
  30. package/dist/esm/hooks/executeHooks.mjs +40 -1
  31. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  32. package/dist/esm/hooks/types.mjs +1 -0
  33. package/dist/esm/hooks/types.mjs.map +1 -1
  34. package/dist/esm/main.mjs +3 -0
  35. package/dist/esm/main.mjs.map +1 -1
  36. package/dist/esm/run.mjs +400 -42
  37. package/dist/esm/run.mjs.map +1 -1
  38. package/dist/esm/tools/ToolNode.mjs +552 -56
  39. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  40. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  41. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  42. package/dist/esm/tools/search/tool.mjs.map +1 -1
  43. package/dist/types/graphs/Graph.d.ts +7 -0
  44. package/dist/types/hitl/askUserQuestion.d.ts +55 -0
  45. package/dist/types/hitl/index.d.ts +6 -0
  46. package/dist/types/hooks/HookRegistry.d.ts +58 -0
  47. package/dist/types/hooks/createToolPolicyHook.d.ts +87 -0
  48. package/dist/types/hooks/index.d.ts +4 -1
  49. package/dist/types/hooks/types.d.ts +109 -3
  50. package/dist/types/index.d.ts +9 -0
  51. package/dist/types/run.d.ts +117 -1
  52. package/dist/types/tools/ToolNode.d.ts +26 -1
  53. package/dist/types/types/hitl.d.ts +272 -0
  54. package/dist/types/types/index.d.ts +1 -0
  55. package/dist/types/types/run.d.ts +33 -0
  56. package/dist/types/types/tools.d.ts +19 -0
  57. package/package.json +1 -1
  58. package/src/graphs/Graph.ts +9 -0
  59. package/src/hitl/askUserQuestion.ts +72 -0
  60. package/src/hitl/index.ts +7 -0
  61. package/src/hooks/HookRegistry.ts +71 -0
  62. package/src/hooks/__tests__/createToolPolicyHook.test.ts +259 -0
  63. package/src/hooks/createToolPolicyHook.ts +184 -0
  64. package/src/hooks/executeHooks.ts +50 -1
  65. package/src/hooks/index.ts +6 -0
  66. package/src/hooks/types.ts +112 -0
  67. package/src/index.ts +19 -0
  68. package/src/run.ts +456 -47
  69. package/src/tools/ToolNode.ts +701 -62
  70. package/src/tools/__tests__/hitl.test.ts +3593 -0
  71. package/src/tools/search/tavily-scraper.ts +4 -4
  72. package/src/tools/search/tavily-search.ts +32 -32
  73. package/src/tools/search/tool.ts +3 -3
  74. package/src/tools/search/types.ts +3 -1
  75. package/src/types/hitl.ts +303 -0
  76. package/src/types/index.ts +1 -0
  77. package/src/types/run.ts +33 -0
  78. package/src/types/tools.ts +19 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  var messages = require('@langchain/core/messages');
4
4
  var langgraph = require('@langchain/langgraph');
5
+ var singletons = require('@langchain/core/singletons');
5
6
  var _enum = require('../common/enum.cjs');
6
7
  require('nanoid');
7
8
  require('../messages/core.cjs');
@@ -21,6 +22,88 @@ var toolOutputReferences = require('./toolOutputReferences.cjs');
21
22
  function isSend(value) {
22
23
  return value instanceof langgraph.Send;
23
24
  }
25
+ /**
26
+ * Format a fail-closed diagnostic for malformed approval-decision
27
+ * fields. Hosts deserialize resume payloads from untyped JSON, so
28
+ * `responseText` and `updatedInput` can land here as anything; the
29
+ * blocking ToolMessage carries this string so the host can debug the
30
+ * exact wire shape that was rejected.
31
+ */
32
+ function describeOfferedShape(value) {
33
+ if (value === undefined) {
34
+ return '<missing>';
35
+ }
36
+ if (value === null) {
37
+ return 'null';
38
+ }
39
+ if (Array.isArray(value)) {
40
+ return 'array';
41
+ }
42
+ return typeof value;
43
+ }
44
+ /**
45
+ * Build the `tool_approval` interrupt payload from the set of pending
46
+ * `ask`-decision entries collected during PreToolUse hook handling.
47
+ * Pure function — doesn't touch ToolNode state — so it lives at module
48
+ * scope. The interrupt itself is raised by the caller (which still
49
+ * needs `interrupt()` plus the AsyncLocalStorage anchoring shim).
50
+ */
51
+ function buildToolApprovalInterruptPayload(askEntries) {
52
+ return {
53
+ type: 'tool_approval',
54
+ action_requests: askEntries.map(({ entry, reason }) => {
55
+ const request = {
56
+ tool_call_id: entry.call.id,
57
+ name: entry.call.name,
58
+ arguments: entry.args,
59
+ };
60
+ if (reason != null) {
61
+ request.description = reason;
62
+ }
63
+ return request;
64
+ }),
65
+ review_configs: askEntries.map(({ entry, allowedDecisions }) => ({
66
+ action_name: entry.call.name,
67
+ tool_call_id: entry.call.id,
68
+ allowed_decisions: (allowedDecisions ?? [
69
+ 'approve',
70
+ 'reject',
71
+ 'edit',
72
+ 'respond',
73
+ ]),
74
+ })),
75
+ };
76
+ }
77
+ /**
78
+ * Build a `tool_call_id → ToolApprovalDecision` map from the host's
79
+ * resume value. Hosts may return decisions either as an array (one per
80
+ * action_request, in order) or as a record keyed by `tool_call_id`. Any
81
+ * unrecognized shape (or a decision missing for a given call id) is
82
+ * treated as "no decision" by callers — typically rejected so the run
83
+ * doesn't silently invoke a tool the human never approved.
84
+ */
85
+ function normalizeApprovalDecisions(callIds, resumeValue) {
86
+ const map = new Map();
87
+ if (resumeValue == null) {
88
+ return map;
89
+ }
90
+ if (Array.isArray(resumeValue)) {
91
+ const limit = Math.min(callIds.length, resumeValue.length);
92
+ for (let i = 0; i < limit; i++) {
93
+ map.set(callIds[i], resumeValue[i]);
94
+ }
95
+ return map;
96
+ }
97
+ if (typeof resumeValue === 'object') {
98
+ for (const callId of callIds) {
99
+ const decision = resumeValue[callId];
100
+ if (decision !== undefined) {
101
+ map.set(callId, decision);
102
+ }
103
+ }
104
+ }
105
+ return map;
106
+ }
24
107
  /**
25
108
  * Merges code execution session context into the sessions map.
26
109
  *
@@ -93,6 +176,12 @@ class ToolNode extends run.RunnableCallable {
93
176
  maxToolResultChars;
94
177
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
95
178
  hookRegistry;
179
+ /**
180
+ * Run-scoped HITL config. When `enabled`, `ask` decisions from
181
+ * PreToolUse hooks raise a LangGraph `interrupt()` instead of being
182
+ * treated as fail-closed denies.
183
+ */
184
+ humanInTheLoop;
96
185
  /**
97
186
  * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
98
187
  *
@@ -113,7 +202,7 @@ class ToolNode extends run.RunnableCallable {
113
202
  * other's in-flight state.
114
203
  */
115
204
  anonBatchCounter = 0;
116
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
205
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
117
206
  super({ name, tags, func: (input, config) => this.run(input, config) });
118
207
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
119
208
  this.toolCallStepIds = toolCallStepIds;
@@ -129,6 +218,7 @@ class ToolNode extends run.RunnableCallable {
129
218
  this.maxToolResultChars =
130
219
  maxToolResultChars ?? truncation.calculateMaxToolResultChars(maxContextTokens);
131
220
  this.hookRegistry = hookRegistry;
221
+ this.humanInTheLoop = humanInTheLoop;
132
222
  /**
133
223
  * Precedence: an explicitly passed `toolOutputRegistry` instance
134
224
  * wins over a config object so a host (`Graph`) can share one
@@ -669,13 +759,40 @@ class ToolNode extends run.RunnableCallable {
669
759
  });
670
760
  const messageByCallId = new Map();
671
761
  const approvedEntries = [];
762
+ /**
763
+ * Batch-level accumulator for `additionalContext` strings returned
764
+ * by any PreToolUse / PostToolUse / PostToolUseFailure hook in this
765
+ * dispatch. We emit one consolidated `HumanMessage` after all tool
766
+ * results land so the next model turn sees the injected context
767
+ * exactly once, ordered after the ToolMessages.
768
+ */
769
+ const batchAdditionalContexts = [];
770
+ /**
771
+ * Batch-level outcome record keyed by `tool_call_id`. Captures
772
+ * every tool call's final result (success / error from the host,
773
+ * blocked from HITL deny / reject, substituted from HITL respond)
774
+ * across the three call sites that touch it. We materialize the
775
+ * `PostToolBatch` entry array in `toolCalls` order at dispatch
776
+ * time so hooks correlating outcomes by position see exactly the
777
+ * same sequence the model emitted — independent of when each
778
+ * outcome was recorded (deny entries land synchronously in the
779
+ * hook loop, approved entries land after host execution, respond
780
+ * entries land in the resume branch).
781
+ */
782
+ const postToolBatchEntryByCallId = new Map();
672
783
  const HOOK_FALLBACK = Object.freeze({
673
784
  additionalContexts: [],
674
785
  errors: [],
675
786
  });
676
787
  if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
788
+ /**
789
+ * Capture as a non-null local so the inner `blockEntry` closure
790
+ * doesn't lose narrowing on `this.hookRegistry` and we don't have
791
+ * to defensively `?.` it across every reference inside.
792
+ */
793
+ const hookRegistry = this.hookRegistry;
677
794
  const preResults = await Promise.all(preToolCalls.map((entry) => executeHooks.executeHooks({
678
- registry: this.hookRegistry,
795
+ registry: hookRegistry,
679
796
  input: {
680
797
  hook_event_name: 'PreToolUse',
681
798
  runId,
@@ -690,79 +807,347 @@ class ToolNode extends run.RunnableCallable {
690
807
  sessionId: runId,
691
808
  matchQuery: entry.call.name,
692
809
  }).catch(() => HOOK_FALLBACK)));
693
- for (let i = 0; i < preToolCalls.length; i++) {
694
- const hookResult = preResults[i];
695
- const entry = preToolCalls[i];
696
- const isDenied = hookResult.decision === 'deny' || hookResult.decision === 'ask';
697
- if (isDenied) {
698
- const reason = hookResult.reason ?? 'Blocked by hook';
699
- const contentString = `Blocked: ${reason}`;
700
- messageByCallId.set(entry.call.id, new messages.ToolMessage({
701
- status: 'error',
702
- content: contentString,
703
- name: entry.call.name,
704
- tool_call_id: entry.call.id,
705
- }));
706
- this.dispatchStepCompleted(entry.call.id, entry.call.name, entry.args, contentString, config);
707
- if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
810
+ /**
811
+ * Side effects deferred from `blockEntry` until after any pending
812
+ * `interrupt()` resolves. Without deferral, a batch that mixes a
813
+ * `deny` decision with an `ask` decision would dispatch
814
+ * `ON_RUN_STEP_COMPLETED` for the denied tool on the FIRST node
815
+ * execution (before `interrupt()` throws), then dispatch the
816
+ * same event AGAIN on the resume re-execution — hosts would
817
+ * observe two completion events for one logical denial. By
818
+ * queueing the dispatch + PermissionDenied hook here and
819
+ * flushing after the interrupt block, we ensure each side effect
820
+ * fires exactly once: never on the first pass when interrupt
821
+ * throws (the flush is unreachable), once on resume / no-ask
822
+ * passes when control reaches the flush.
823
+ */
824
+ const deferredBlockedSideEffects = [];
825
+ const blockEntry = (entry, reason) => {
826
+ const contentString = `Blocked: ${reason}`;
827
+ messageByCallId.set(entry.call.id, new messages.ToolMessage({
828
+ status: 'error',
829
+ content: contentString,
830
+ name: entry.call.name,
831
+ tool_call_id: entry.call.id,
832
+ }));
833
+ postToolBatchEntryByCallId.set(entry.call.id, {
834
+ toolName: entry.call.name,
835
+ toolInput: entry.args,
836
+ toolUseId: entry.call.id,
837
+ stepId: entry.stepId,
838
+ /**
839
+ * Records the pre-invocation turn count — the same value the
840
+ * executed path captures before incrementing `toolUsageCount`.
841
+ * For a blocked tool the counter is never incremented (no
842
+ * invocation happened), so this is always the count of prior
843
+ * successful invocations of this tool name in earlier batches.
844
+ * Surfaces in the `PostToolBatch` entry so batch hooks see
845
+ * a uniform shape regardless of outcome.
846
+ */
847
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
848
+ status: 'error',
849
+ error: contentString,
850
+ });
851
+ deferredBlockedSideEffects.push({
852
+ callId: entry.call.id,
853
+ toolName: entry.call.name,
854
+ args: entry.args,
855
+ contentString,
856
+ reason,
857
+ });
858
+ };
859
+ const flushDeferredBlockedSideEffects = () => {
860
+ for (const item of deferredBlockedSideEffects) {
861
+ this.dispatchStepCompleted(item.callId, item.toolName, item.args, item.contentString, config);
862
+ if (hookRegistry.hasHookFor('PermissionDenied', runId)) {
708
863
  executeHooks.executeHooks({
709
- registry: this.hookRegistry,
864
+ registry: hookRegistry,
710
865
  input: {
711
866
  hook_event_name: 'PermissionDenied',
712
867
  runId,
713
868
  threadId,
714
869
  agentId: this.agentId,
715
- toolName: entry.call.name,
716
- toolInput: entry.args,
717
- toolUseId: entry.call.id,
718
- reason,
870
+ toolName: item.toolName,
871
+ toolInput: item.args,
872
+ toolUseId: item.callId,
873
+ reason: item.reason,
719
874
  },
720
875
  sessionId: runId,
721
- matchQuery: entry.call.name,
876
+ matchQuery: item.toolName,
722
877
  }).catch(() => {
723
878
  /* PermissionDenied is observational — swallow errors */
724
879
  });
725
880
  }
881
+ }
882
+ deferredBlockedSideEffects.length = 0;
883
+ };
884
+ /**
885
+ * Apply a hook-supplied or host-supplied input override to a pending
886
+ * entry, re-running the `{{tool<i>turn<n>}}` resolver so any new
887
+ * placeholders introduced by the override are substituted (and any
888
+ * formerly-unresolved refs cleared from the unresolved set).
889
+ *
890
+ * Mixed direct+event batches must use the pre-batch snapshot so a
891
+ * hook-introduced placeholder cannot accidentally resolve to a
892
+ * same-turn direct output that has just registered. Pure event
893
+ * batches don't have a snapshot and resolve against the live
894
+ * registry — safe because no event-side registrations have happened
895
+ * yet.
896
+ */
897
+ const applyInputOverride = (entry, nextArgs) => {
898
+ if (registry != null) {
899
+ const view = preBatchSnapshot ?? {
900
+ resolve: (args) => registry.resolve(registryRunId, args),
901
+ };
902
+ const { resolved, unresolved } = view.resolve(nextArgs);
903
+ entry.args = resolved;
904
+ if (entry.call.id != null) {
905
+ if (unresolved.length > 0) {
906
+ unresolvedByCallId.set(entry.call.id, unresolved);
907
+ }
908
+ else {
909
+ unresolvedByCallId.delete(entry.call.id);
910
+ }
911
+ }
912
+ return;
913
+ }
914
+ entry.args = nextArgs;
915
+ };
916
+ const askEntries = [];
917
+ for (let i = 0; i < preToolCalls.length; i++) {
918
+ const hookResult = preResults[i];
919
+ const entry = preToolCalls[i];
920
+ for (const ctx of hookResult.additionalContexts) {
921
+ batchAdditionalContexts.push(ctx);
922
+ }
923
+ if (hookResult.decision === 'deny') {
924
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
925
+ continue;
926
+ }
927
+ if (hookResult.decision === 'ask') {
928
+ /**
929
+ * HITL is OFF by default — hosts must explicitly opt in via
930
+ * `humanInTheLoop: { enabled: true }` to engage the
931
+ * `interrupt()` path. When opted out (or omitted), `ask`
932
+ * collapses into the pre-HITL fail-closed path: a blocked
933
+ * tool with an error `ToolMessage`. The default stays
934
+ * conservative until host UIs are ready to render
935
+ * `tool_approval` interrupts; see `HumanInTheLoopConfig`
936
+ * JSDoc for the full rationale and the migration plan.
937
+ */
938
+ if (this.humanInTheLoop?.enabled !== true) {
939
+ blockEntry(entry, hookResult.reason ?? 'Blocked by hook');
940
+ continue;
941
+ }
942
+ /**
943
+ * Apply `updatedInput` BEFORE queuing into `askEntries` —
944
+ * a hook is allowed to return both a sanitization rewrite
945
+ * and an `ask` decision (e.g. one matcher redacts secrets,
946
+ * another matcher requires approval). Without this, the
947
+ * interrupt payload would surface the original args to the
948
+ * reviewer AND the post-approve execution would run with
949
+ * the original args, silently dropping the hook's rewrite.
950
+ */
951
+ if (hookResult.updatedInput != null) {
952
+ applyInputOverride(entry, hookResult.updatedInput);
953
+ }
954
+ askEntries.push({
955
+ entry,
956
+ reason: hookResult.reason,
957
+ allowedDecisions: hookResult.allowedDecisions,
958
+ });
726
959
  continue;
727
960
  }
728
961
  if (hookResult.updatedInput != null) {
962
+ applyInputOverride(entry, hookResult.updatedInput);
963
+ }
964
+ approvedEntries.push(entry);
965
+ }
966
+ /**
967
+ * If any entries asked for approval, raise a single LangGraph
968
+ * `interrupt()` carrying every pending request together. The host
969
+ * pauses, gathers human input, and resumes the run with one
970
+ * decision per request. On resume LangGraph re-executes this node
971
+ * from the start; `interrupt()` then returns the resume value
972
+ * instead of throwing, so the loop above re-runs and the same
973
+ * `askEntries` list is rebuilt deterministically (assuming hooks
974
+ * are pure — see `humanInTheLoop` docs).
975
+ */
976
+ if (askEntries.length > 0) {
977
+ const payload = buildToolApprovalInterruptPayload(askEntries);
978
+ /**
979
+ * `interrupt()` reads the current `RunnableConfig` from
980
+ * AsyncLocalStorage, but our `RunnableCallable` sets
981
+ * `trace = false` for ToolNode (intentional — avoids LangSmith
982
+ * tracing per tool call). Without the trace path, the upstream
983
+ * `runWithConfig` frame is never established, so we re-anchor
984
+ * here using the node's own `config` — Pregel hands us a
985
+ * config that already carries every checkpoint/scratchpad key
986
+ * `interrupt()` needs to suspend and resume.
987
+ */
988
+ const resumeValue = singletons.AsyncLocalStorageProviderSingleton.runWithConfig(config, () => langgraph.interrupt(payload));
989
+ const decisionByCallId = normalizeApprovalDecisions(askEntries.map(({ entry }) => entry.call.id), resumeValue);
990
+ for (const { entry, reason: askReason, allowedDecisions, } of askEntries) {
991
+ const decision = decisionByCallId.get(entry.call.id) ?? {
992
+ type: 'reject',
993
+ reason: 'No decision provided for tool approval',
994
+ };
729
995
  /**
730
- * Re-resolve after PreToolUse replaces the input: a hook may
731
- * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
732
- * copying user-supplied text) that the pre-hook pass never
733
- * saw. Re-running the resolver on the hook-rewritten args
734
- * keeps substitution and the unresolved-refs record in sync
735
- * with what the tool will actually receive.
996
+ * Read `decision.type` through a widened view once: hosts
997
+ * deserialize resume payloads from untyped JSON, so the
998
+ * runtime value can be a typo, the wrong type, or missing
999
+ * entirely. Both the `allowedDecisions` enforcement
1000
+ * immediately below and the unknown-type fallthrough at the
1001
+ * end of this loop body share this single read so the
1002
+ * fail-closed checks compare against the same source.
736
1003
  */
737
- if (registry != null) {
1004
+ const declaredType = decision.type;
1005
+ /**
1006
+ * Enforce the per-tool `allowedDecisions` allowlist that the
1007
+ * `PreToolUse` hook surfaced in `review_configs`. The host
1008
+ * UI is supposed to honor this when collecting the user's
1009
+ * decision, but the wire is untrusted: a buggy or hostile
1010
+ * host could submit a decision type the policy explicitly
1011
+ * forbids (e.g. `'edit'` when the hook restricted to
1012
+ * `['approve', 'reject']`), bypassing argument-mutation /
1013
+ * response-substitution safeguards. Fail closed when the
1014
+ * declared type isn't in the allowlist.
1015
+ */
1016
+ if (allowedDecisions != null &&
1017
+ (typeof declaredType !== 'string' ||
1018
+ !allowedDecisions.includes(declaredType))) {
1019
+ const offered = typeof declaredType === 'string' ? declaredType : '<missing>';
1020
+ blockEntry(entry, `Decision "${offered}" not in allowedDecisions [${allowedDecisions.join(', ')}] — failing closed`);
1021
+ continue;
1022
+ }
1023
+ if (decision.type === 'reject') {
1024
+ blockEntry(entry, decision.reason ?? askReason ?? 'Rejected by user');
1025
+ continue;
1026
+ }
1027
+ /**
1028
+ * `respond` short-circuits tool execution: the human supplies
1029
+ * the result the model should see in place of running the
1030
+ * tool. We emit a successful `ToolMessage` directly and skip
1031
+ * dispatch — no host event fires, no real tool side effect
1032
+ * occurs. Mirrors LangChain HITL middleware semantics.
1033
+ */
1034
+ if (decision.type === 'respond') {
738
1035
  /**
739
- * Mixed direct+event batches must use the pre-batch
740
- * snapshot so a hook-introduced placeholder cannot
741
- * accidentally resolve to a same-turn direct output that
742
- * has just registered. Pure event batches don't have a
743
- * snapshot and resolve against the live registry — safe
744
- * because no event-side registrations have happened yet.
1036
+ * Validate the wire shape before touching it: hosts
1037
+ * deserialize resume payloads from untyped JSON, so a
1038
+ * malformed `{ type: 'respond' }` (no `responseText`) or
1039
+ * `{ type: 'respond', responseText: 42 }` would crash
1040
+ * `truncateToolResultContent` (which calls
1041
+ * `content.length`) and turn a fail-closed approval path
1042
+ * into a hard run failure. Route bad shapes through
1043
+ * `blockEntry` like any other unusable decision.
745
1044
  */
746
- const view = preBatchSnapshot ?? {
747
- resolve: (args) => registry.resolve(registryRunId, args),
748
- };
749
- const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
750
- entry.args = resolved;
751
- if (entry.call.id != null) {
752
- if (unresolved.length > 0) {
753
- unresolvedByCallId.set(entry.call.id, unresolved);
754
- }
755
- else {
756
- unresolvedByCallId.delete(entry.call.id);
757
- }
1045
+ const responseText = decision
1046
+ .responseText;
1047
+ if (typeof responseText !== 'string') {
1048
+ blockEntry(entry, `Decision "respond" missing string responseText (got ${describeOfferedShape(responseText)}) — failing closed`);
1049
+ continue;
758
1050
  }
1051
+ /**
1052
+ * Truncate the human-supplied text just like the success
1053
+ * path does for real tool output. Without this, a user
1054
+ * pasting a large document as a manual response bypasses
1055
+ * `maxToolResultChars` and can blow past the model's
1056
+ * context window. The PostToolBatch entry surfaces the
1057
+ * truncated text too so batch hooks see what the model
1058
+ * will actually see.
1059
+ */
1060
+ const truncatedResponse = truncation.truncateToolResultContent(responseText, this.maxToolResultChars);
1061
+ messageByCallId.set(entry.call.id, new messages.ToolMessage({
1062
+ status: 'success',
1063
+ content: truncatedResponse,
1064
+ name: entry.call.name,
1065
+ tool_call_id: entry.call.id,
1066
+ }));
1067
+ postToolBatchEntryByCallId.set(entry.call.id, {
1068
+ toolName: entry.call.name,
1069
+ toolInput: entry.args,
1070
+ toolUseId: entry.call.id,
1071
+ stepId: entry.stepId,
1072
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
1073
+ status: 'success',
1074
+ toolOutput: truncatedResponse,
1075
+ });
1076
+ /**
1077
+ * Safe to dispatch immediately — unlike `blockEntry` which
1078
+ * defers, `respond` only executes inside the decision-
1079
+ * processing loop, which is reachable only AFTER
1080
+ * `interrupt()` has returned (the resume pass). There is
1081
+ * no risk of being rolled back by a subsequent throw, so
1082
+ * no risk of a duplicate `ON_RUN_STEP_COMPLETED` event.
1083
+ */
1084
+ this.dispatchStepCompleted(entry.call.id, entry.call.name, entry.args, truncatedResponse, config);
1085
+ continue;
759
1086
  }
760
- else {
761
- entry.args = hookResult.updatedInput;
1087
+ if (decision.type === 'edit') {
1088
+ /**
1089
+ * Validate the wire shape before touching it: hosts
1090
+ * deserialize resume payloads from untyped JSON, so a
1091
+ * malformed `{ type: 'edit' }` (no `updatedInput`),
1092
+ * `{ type: 'edit', updatedInput: 'string' }` (non-object),
1093
+ * or `{ type: 'edit', updatedInput: [...] }` (array, not a
1094
+ * plain object) would feed garbage into
1095
+ * `applyInputOverride` and silently approve a tool with
1096
+ * undefined / wrong-shape args. Same trust boundary as
1097
+ * the `respond` validation above — fail closed via
1098
+ * `blockEntry` with a diagnostic.
1099
+ */
1100
+ const updatedInput = decision
1101
+ .updatedInput;
1102
+ if (updatedInput === null ||
1103
+ typeof updatedInput !== 'object' ||
1104
+ Array.isArray(updatedInput)) {
1105
+ blockEntry(entry, `Decision "edit" missing object updatedInput (got ${describeOfferedShape(updatedInput)}) — failing closed`);
1106
+ continue;
1107
+ }
1108
+ applyInputOverride(entry, updatedInput);
1109
+ approvedEntries.push(entry);
1110
+ continue;
762
1111
  }
1112
+ /**
1113
+ * Defensive type widening: hosts deserialize resume payloads
1114
+ * from untyped JSON, so the `decision.type` value at runtime
1115
+ * is whatever string the wire sent — not necessarily one of
1116
+ * the four union variants TS knows about. We compare against
1117
+ * the literal `'approve'` through the widened `declaredType`
1118
+ * captured at the top of this iteration, so a typo or schema
1119
+ * drift (`'aproved'`, `null`, `undefined`) hits the fail-
1120
+ * closed branch below instead of silently approving the
1121
+ * tool. Without this widening, TS narrows the union after
1122
+ * the three earlier branches and treats `=== 'approve'` as
1123
+ * trivially true.
1124
+ */
1125
+ if (declaredType === 'approve') {
1126
+ approvedEntries.push(entry);
1127
+ continue;
1128
+ }
1129
+ /**
1130
+ * Unknown / missing decision type — fail closed. The whole
1131
+ * point of an approval gate is that "no decision" or
1132
+ * "garbled decision" deny by default.
1133
+ */
1134
+ const unknownType = typeof declaredType === 'string' ? declaredType : '<missing>';
1135
+ blockEntry(entry, `Unknown approval decision type "${unknownType}" — failing closed`);
763
1136
  }
764
- approvedEntries.push(entry);
765
1137
  }
1138
+ /**
1139
+ * Flush deferred denial side effects exactly once. On the FIRST
1140
+ * pass through a batch that contains an `ask`, `interrupt()`
1141
+ * threw above and we never reach this line — so no
1142
+ * `ON_RUN_STEP_COMPLETED` / `PermissionDenied` events fire
1143
+ * for blocked tools yet. On resume the node re-executes from
1144
+ * scratch, `blockEntry` re-queues the same entries, and the
1145
+ * flush below dispatches them once. For batches without any
1146
+ * `ask` (deny-only or empty), the flush still runs here and
1147
+ * dispatches in the same relative position as the pre-deferral
1148
+ * code did (after hook processing, before tool execution).
1149
+ */
1150
+ flushDeferredBlockedSideEffects();
766
1151
  }
767
1152
  else {
768
1153
  approvedEntries.push(...preToolCalls);
@@ -831,6 +1216,15 @@ class ToolNode extends run.RunnableCallable {
831
1216
  const toolName = request?.name ?? 'unknown';
832
1217
  let contentString;
833
1218
  let toolMessage;
1219
+ /**
1220
+ * Tracks the post-PostToolUse-hook output so the
1221
+ * `PostToolBatch` entry below sees the final transformed value
1222
+ * even when a hook replaced the original via `updatedOutput`.
1223
+ * Lives at the loop-iteration scope so the success branch can
1224
+ * mutate it; the error branch leaves it unset (and the batch
1225
+ * entry uses `error` instead of `toolOutput` in that case).
1226
+ */
1227
+ let finalToolOutput = result.content;
834
1228
  if (result.status === 'error') {
835
1229
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
836
1230
  /**
@@ -854,7 +1248,7 @@ class ToolNode extends run.RunnableCallable {
854
1248
  }),
855
1249
  });
856
1250
  if (hasFailureHook) {
857
- await executeHooks.executeHooks({
1251
+ const failureHookResult = await executeHooks.executeHooks({
858
1252
  registry: this.hookRegistry,
859
1253
  input: {
860
1254
  hook_event_name: 'PostToolUseFailure',
@@ -870,9 +1264,21 @@ class ToolNode extends run.RunnableCallable {
870
1264
  },
871
1265
  sessionId: runId,
872
1266
  matchQuery: toolName,
873
- }).catch(() => {
874
- /* PostToolUseFailure is observational — swallow errors */
875
- });
1267
+ }).catch(() => undefined);
1268
+ /**
1269
+ * Collect `additionalContext` from failure hooks too. Without
1270
+ * this, recovery guidance returned on tool errors (e.g.
1271
+ * "if this tool errors with X, suggest Y to the user") is
1272
+ * silently dropped even though the API surface advertises
1273
+ * `additionalContext` for this event. PostToolUseFailure
1274
+ * remains observational for errors thrown by the hook
1275
+ * itself, but a successfully-returned result is honored.
1276
+ */
1277
+ if (failureHookResult != null) {
1278
+ for (const ctx of failureHookResult.additionalContexts) {
1279
+ batchAdditionalContexts.push(ctx);
1280
+ }
1281
+ }
876
1282
  }
877
1283
  }
878
1284
  else {
@@ -898,12 +1304,18 @@ class ToolNode extends run.RunnableCallable {
898
1304
  sessionId: runId,
899
1305
  matchQuery: toolName,
900
1306
  }).catch(() => undefined);
1307
+ if (hookResult != null) {
1308
+ for (const ctx of hookResult.additionalContexts) {
1309
+ batchAdditionalContexts.push(ctx);
1310
+ }
1311
+ }
901
1312
  if (hookResult?.updatedOutput != null) {
902
1313
  const replaced = typeof hookResult.updatedOutput === 'string'
903
1314
  ? hookResult.updatedOutput
904
1315
  : JSON.stringify(hookResult.updatedOutput);
905
1316
  registryRaw = replaced;
906
1317
  contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
1318
+ finalToolOutput = hookResult.updatedOutput;
907
1319
  }
908
1320
  }
909
1321
  const batchIndex = batchIndexByCallId.get(result.toolCallId);
@@ -926,14 +1338,98 @@ class ToolNode extends run.RunnableCallable {
926
1338
  });
927
1339
  }
928
1340
  this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
1341
+ postToolBatchEntryByCallId.set(result.toolCallId, {
1342
+ toolName,
1343
+ toolInput: request?.args ?? {},
1344
+ toolUseId: result.toolCallId,
1345
+ stepId: request?.stepId,
1346
+ turn: request?.turn,
1347
+ status: result.status === 'error' ? 'error' : 'success',
1348
+ ...(result.status === 'error'
1349
+ ? { error: result.errorMessage ?? 'Unknown error' }
1350
+ : { toolOutput: finalToolOutput }),
1351
+ });
929
1352
  messageByCallId.set(result.toolCallId, toolMessage);
930
1353
  }
931
1354
  }
932
1355
  const toolMessages = toolCalls
933
1356
  .map((call) => messageByCallId.get(call.id))
934
1357
  .filter((m) => m != null);
1358
+ await this.dispatchPostToolBatchAndInjectContext({
1359
+ toolCalls,
1360
+ entriesByCallId: postToolBatchEntryByCallId,
1361
+ batchAdditionalContexts,
1362
+ injected,
1363
+ runId,
1364
+ threadId,
1365
+ });
935
1366
  return { toolMessages, injected };
936
1367
  }
1368
+ /**
1369
+ * Fires the `PostToolBatch` hook (if registered) and appends the
1370
+ * accumulated batch-level `additionalContext` strings to `injected`
1371
+ * as a single `HumanMessage`. Entries are materialized in the
1372
+ * original `toolCalls` order so hooks correlating outcomes by
1373
+ * position (as the type docs promise) see exactly the sequence
1374
+ * the model emitted, regardless of when each individual outcome
1375
+ * was recorded into the map (deny synchronous, approved
1376
+ * post-execution, respond on resume).
1377
+ *
1378
+ * The PostToolBatch hook's `additionalContexts` flow into the same
1379
+ * batch accumulator per-tool hooks already use, so a single
1380
+ * batch-level convention message can be injected through one path.
1381
+ *
1382
+ * Mutates `batchAdditionalContexts` (push from batch hook) and
1383
+ * `injected` (push the consolidated HumanMessage). The caller owns
1384
+ * those arrays and consumes them right after this returns.
1385
+ */
1386
+ async dispatchPostToolBatchAndInjectContext(args) {
1387
+ const { toolCalls, entriesByCallId, batchAdditionalContexts, injected, runId, threadId, } = args;
1388
+ const orderedBatchEntries = [];
1389
+ for (const call of toolCalls) {
1390
+ const callId = call.id;
1391
+ if (callId == null) {
1392
+ continue;
1393
+ }
1394
+ const entry = entriesByCallId.get(callId);
1395
+ if (entry != null) {
1396
+ orderedBatchEntries.push(entry);
1397
+ }
1398
+ }
1399
+ if (this.hookRegistry?.hasHookFor('PostToolBatch', runId) === true &&
1400
+ orderedBatchEntries.length > 0) {
1401
+ const batchHookResult = await executeHooks.executeHooks({
1402
+ registry: this.hookRegistry,
1403
+ input: {
1404
+ hook_event_name: 'PostToolBatch',
1405
+ runId,
1406
+ threadId,
1407
+ agentId: this.agentId,
1408
+ entries: orderedBatchEntries,
1409
+ },
1410
+ sessionId: runId,
1411
+ }).catch(() => undefined);
1412
+ if (batchHookResult != null) {
1413
+ for (const ctx of batchHookResult.additionalContexts) {
1414
+ batchAdditionalContexts.push(ctx);
1415
+ }
1416
+ }
1417
+ }
1418
+ if (batchAdditionalContexts.length > 0) {
1419
+ /**
1420
+ * `HumanMessage` carrying a metadata `role: 'system'` marker —
1421
+ * see `convertInjectedMessages` for the wider rationale. Anthropic
1422
+ * and Google reject mid-conversation `SystemMessage`s, so we use
1423
+ * a user-role message and surface the system intent through
1424
+ * `additional_kwargs` for hosts inspecting state. The model sees
1425
+ * a user message; `role` is metadata only.
1426
+ */
1427
+ injected.push(new messages.HumanMessage({
1428
+ content: batchAdditionalContexts.join('\n\n'),
1429
+ additional_kwargs: { role: 'system', source: 'hook' },
1430
+ }));
1431
+ }
1432
+ }
937
1433
  dispatchStepCompleted(toolCallId, toolName, args, output, config, turn) {
938
1434
  const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
939
1435
  if (!stepId) {