-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathagent.py
More file actions
7251 lines (6610 loc) · 346 KB
/
Copy pathagent.py
File metadata and controls
7251 lines (6610 loc) · 346 KB
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
"""AWS Strands Agent adapter for AG-UI.
Translates Strands streaming events into the AG-UI event protocol.
"""
import asyncio
import base64
import hashlib
import functools
import inspect
import json
import logging
import math
import collections.abc
from copy import deepcopy
import types
import typing
import uuid
import weakref
from contextvars import ContextVar
from datetime import datetime, timezone
from importlib.metadata import version as distribution_version
from typing import (
Any,
AsyncIterator,
Container,
Dict,
List,
Mapping,
Optional,
Sequence,
Tuple,
)
from strands import Agent as StrandsAgentCore
from strands.hooks import AfterModelCallEvent, BeforeModelCallEvent
from strands.session import SessionManager
from strands.types.interrupt import InterruptResponseContent
# Params handled explicitly by StrandsAgent — excluded from auto-forwarding.
# "messages" is excluded: per-thread agents start with no history;
# AG-UI injects messages at runtime via RunAgentInput.
# "hooks" is excluded: Agent stores hooks as a HookRegistry after init, not
# the original list the constructor expects — forwarding it causes a TypeError.
# "session_manager" is excluded: it is supplied per-thread via
# StrandsAgentConfig.session_manager_provider (see run()). Forwarding a
# template-level session_manager would make every thread share one session_id.
# "plugins" is excluded: Agent consumes the list during init, registering each
# plugin's hooks and tools into its own registries and keeping only a registry
# bound to that agent, so there is no list to read back. Callers supply them
# per-thread through the explicit StrandsAgent(plugins=...) kwarg.
_AGUI_EXPLICIT_PARAMS = {
"self",
"model",
"system_prompt",
"tools",
"messages",
"hooks",
"session_manager",
"plugins",
}
_MISSING = object()
_AGENT_BOUND = object()
def _candidate_attributes(name: str) -> tuple[str, ...]:
"""Attribute names Strands might be keeping constructor param ``name`` under.
Strands does not guarantee that a constructor param is readable back under
its own name, and which convention it picks has changed release to release.
Rather than tracking each param by name, probe the conventions themselves:
* ``name`` kept verbatim (``conversation_manager``)
* ``_name`` private alias (``_retry_strategy``)
* ``_default_name`` renamed on the way in
(``_default_structured_output_model``)
* ``_<singular>_registry`` / ``_name_registry``
consumed into a registry (``_intervention_registry``
from ``interventions``)
A param that follows one of these is carried across without this adapter
being taught about it individually.
Deliberately not probed: the same name on some other object the Agent
happens to hold. That matched on spelling rather than on storage, and what
it turned up was coincidence as often as the real value.
"""
singular = name[:-1] if name.endswith("s") else name
candidates = (
name,
f"_{name}",
f"_default_{name}",
f"_{singular}_registry",
f"_{name}_registry",
)
# A non-plural name makes the two registry forms identical, and probing a
# candidate twice invokes the registry's accessors twice.
return tuple(dict.fromkeys(candidates))
def _own_attributes(holder: Any) -> dict:
"""``vars(holder)``, or an empty mapping when the object has no ``__dict__``.
A registry defined with ``__slots__`` has no instance dict, and probing one
must not take down the adapter's constructor.
"""
try:
return vars(holder)
except TypeError:
return {}
def _references_agent(holder: Any, agent: Any) -> bool:
"""Whether ``holder`` keeps a reference back to ``agent`` itself.
Checked against the specific agent rather than "holds any weak reference",
so an unrelated cache does not read as ownership.
"""
for value in _own_attributes(holder).values():
if value is agent:
return True
if isinstance(value, weakref.ReferenceType):
try:
if value() is agent:
return True
except Exception: # noqa: BLE001 - a dead or exotic ref is not ownership
continue
if isinstance(value, weakref.ProxyTypes):
try:
if value.__class__ is agent.__class__ and value == agent:
return True
except Exception: # noqa: BLE001 - proxies raise once the referent is gone
continue
return False
def _registry_contents(holder: Any) -> Any:
"""The values a registry was built from, or ``_MISSING``.
Prefers a public accessor, since that is the surface Strands supports, and
falls back to a private backing collection for registries that expose none.
Dict-backed registries are keyed by name, so hand back the values.
The returned container is a fresh object either way. Element identity is
preserved, which is what the constructor actually consumes.
"""
accessors = [
v
for klass in type(holder).__mro__
for k, v in vars(klass).items()
if isinstance(v, property) and not k.startswith("_")
]
def _read(prop: property) -> Any:
# A registry accessor is arbitrary code. It may raise or depend on
# state the template no longer has; that is a reason to try the next
# source, not to fail constructing the adapter.
try:
return prop.fget(holder) if prop.fget is not None else _MISSING
except Exception: # noqa: BLE001 - any accessor failure means "try the next source"
return _MISSING
backing = [
v
for k, v in _own_attributes(holder).items()
if k.startswith("_") and isinstance(v, (list, tuple, dict))
]
for source in ([_read(prop) for prop in accessors], backing):
for value in source:
if isinstance(value, dict):
return list(value.values())
if isinstance(value, (list, tuple)):
return list(value)
return _MISSING
# Whether the installed Strands takes ``plugins`` on its Agent constructor.
# The plugin system arrived after this package's declared strands-agents floor,
# so the adapter's own ``plugins=`` kwarg can be handed a release with nowhere
# to put it. Probed off the signature rather than compared against a version,
# for the same reason the forwarding probe is: what matters is the parameter
# being there, not which release put it there.
_STRANDS_ACCEPTS_PLUGINS = (
"plugins" in inspect.signature(StrandsAgentCore.__init__).parameters
)
# Strands namespaces the plugins it registers on every Agent itself, and
# registers them whether or not the caller passed any. Anything under this
# prefix is therefore the SDK's, not a setting to report as dropped.
_SDK_PLUGIN_NAME_PREFIX = "strands:"
def _template_plugin_names(agent: Any) -> List[str]:
"""Names of the plugins the caller put on the template.
``plugins`` is handled through an explicit kwarg, so the generic probe
skips it and would never report it. Reading the registry here is what
lets a caller who set plugins on the template be told they do not carry,
instead of getting silence.
Strands' own plugins are filtered out by name. Every Agent is built with
at least one of them, so counting them would warn every caller about a
setting nobody made. A caller plugin that borrowed the SDK's prefix would
be missed by this, which is the harmless direction: the cost is one
warning not said, against a warning said to everyone.
"""
for attr in _candidate_attributes("plugins"):
try:
holder = getattr(agent, attr, None)
except Exception: # noqa: BLE001 - a raising property is not a plugin list
continue
if holder is None:
continue
if isinstance(holder, (list, tuple)):
contents: Any = holder
else:
contents = _registry_contents(holder)
if contents is _MISSING or not contents:
continue
names = []
for plugin in contents:
name = getattr(plugin, "name", None)
# An entry with no readable name cannot be attributed to the SDK,
# so it counts as the caller's rather than being dropped silently.
label = name if isinstance(name, str) else type(plugin).__name__
if not label.startswith(_SDK_PLUGIN_NAME_PREFIX):
names.append(label)
if names:
return names
return []
def _element_type(annotation: Any) -> Any:
"""The element type of a ``list[X]``-shaped annotation, or ``None``.
Looks through an optional wrapper first: nearly every Strands param is
declared ``X | None``, and reading only the outer type made this return
``None`` for all of them, which silently disabled the check below.
"""
origin = typing.get_origin(annotation)
if origin is typing.Union or origin is types.UnionType:
for arg in typing.get_args(annotation):
if arg is type(None):
continue
element = _element_type(arg)
if element is not None:
return element
return None
if origin not in (list, tuple, collections.abc.Sequence):
return None
args = typing.get_args(annotation)
element = args[0] if args else None
return element if isinstance(element, type) else None
def _looks_like(value: Any, annotation: Any) -> bool:
"""Whether ``value`` could plausibly be what ``annotation`` declares.
Convention probing matches on where a value is stored, which is a guess. A
registry that happens to expose some other collection would otherwise be
forwarded as the parameter, and the constructor would either reject it or,
worse, accept nonsense. Checking the declared element type turns that into
"not found" instead.
Unknown or unresolvable annotations pass: the point is to reject a
confident wrong answer, not to require a type for everything.
"""
element = _element_type(annotation)
if element is None or not isinstance(value, list):
return True
try:
return all(isinstance(item, element) for item in value)
except TypeError:
# Some annotations cannot be used with isinstance at all (a Protocol
# that is not runtime-checkable, a parameterized generic on older
# interpreters). Unable to judge is not the same as wrong.
return True
def _resolve_template_param(agent: Any, name: str, annotation: Any = None) -> Any:
"""Recover constructor param ``name`` from a built agent.
Returns the value, ``_AGENT_BOUND`` when it is wired to the template and
cannot be handed to another agent, or ``_MISSING`` when no storage
convention matches.
A candidate holding ``None`` does not end the search: Strands sometimes
exposes a param under its own name before it is populated, and stopping
there would mask the alias that actually holds the value.
"""
fallback = _MISSING
for attr in _candidate_attributes(name):
try:
# One lookup, not hasattr followed by getattr: these can be
# properties, and probing twice runs the caller's code twice.
value = getattr(agent, attr, _MISSING)
except Exception: # noqa: BLE001 - a raising property is not a reason to fail init
continue
if value is _MISSING:
continue
if attr.endswith("_registry") and not name.endswith("_registry"):
if _references_agent(value, agent):
return _AGENT_BOUND
contents = _registry_contents(value)
if contents is _MISSING:
continue
if not contents:
# An empty registry means the caller set nothing. Keep looking:
# another convention may hold the value that was set.
fallback = None
continue
if not _looks_like(contents, annotation):
continue
return contents
if value is None:
fallback = None
continue
return value
return fallback
def _forwardable_parameters() -> List[Tuple[str, Any]]:
"""Constructor params this adapter is responsible for carrying, with types.
``*args`` / ``**kwargs`` are not params a caller sets on the template, so
they are not something that can be dropped.
"""
try:
hints = typing.get_type_hints(StrandsAgentCore.__init__)
except Exception as e: # noqa: BLE001 - the SDK's own namespace, not ours to fix
# Raw annotations still work for the checks below, but they resolve
# differently, so leave a trace rather than degrading in silence.
logger.debug(
"could not resolve Strands Agent.__init__ annotations (%s: %s); "
"falling back to raw annotations",
type(e).__name__,
e,
)
hints = {}
out: List[Tuple[str, Any]] = []
for name, param in inspect.signature(StrandsAgentCore.__init__).parameters.items():
if name in _AGUI_EXPLICIT_PARAMS:
continue
if param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL):
continue
out.append((name, hints.get(name, param.annotation)))
return out
def _extract_agent_kwargs(
agent: StrandsAgentCore,
) -> Tuple[dict, List[str], List[str]]:
"""Build kwargs for StrandsAgentCore by introspecting its constructor signature.
Returns the recovered kwargs, the params that could not be read back at all,
and the params that were read but belong to the template.
The two failure lists are kept apart because they need different answers.
An unreadable param is a gap in this adapter: Strands keeps adding
constructor params it does not store under their own name, and every one of
those used to be dropped in silence, so the caller is warned. A param wired
to the template is a structural property of the SDK rather than a surprise,
so it is recorded without a warning.
"""
kwargs: dict = {}
unreadable: List[str] = []
template_owned: List[str] = []
for name, annotation in _forwardable_parameters():
value = _resolve_template_param(agent, name, annotation)
if value is _MISSING:
unreadable.append(name)
continue
if value is _AGENT_BOUND:
template_owned.append(name)
continue
if value is None:
continue
# state is an AgentState container; extract the underlying plain dict
if name == "state":
get = getattr(value, "get", None)
if callable(get) and not isinstance(value, dict):
try:
value = get()
except TypeError:
pass
kwargs[name] = value
return kwargs, unreadable, template_owned
# Upper bound on the per-agent frontend-call id store held in session state.
# Bounds growth from frontend calls that never receive a client result
# (abandoned HITL) and so are never consumed/pruned. Generous: a thread rarely
# has this many outstanding frontend calls at once.
_FRONTEND_CALL_IDS_MAX = 512
# Upper bound on the per-agent tool-call metadata map held in session state.
# It bounds abandoned entries (tool calls whose result never returns)
# so state cannot grow without bound.
_TOOL_CALL_MAP_MAX = 512
# Request-scoped model context. A ContextVar keeps concurrent runs isolated;
# the hook below injects it only for the model call and restores the durable
# conversation immediately afterward.
_MODEL_CONTEXT_BLOCK: ContextVar[str] = ContextVar(
"ag_ui_strands_model_context_block", default=""
)
_MODEL_CONTEXT_HOOK_MARKER = "_ag_ui_transient_model_context_hook"
_MODEL_CONTEXT_MUTATION_MARKER = "_ag_ui_transient_model_context_mutation"
def _exception_text(exc: BaseException) -> str:
"""``str(exc)`` that cannot itself raise.
``__str__`` is arbitrary code, so reading a failure's text is a call that
can fail. It is read only to build a ``_ForeignFault``, where a raise would
escape as the very ``TypeError`` that wrapper exists to keep out of
``ADAPTER_BUG``. A text that cannot be read falls back to the type name,
which still tells the reader what failed.
"""
try:
return str(exc)
except Exception:
return type(exc).__name__
class _ForeignFault(Exception):
"""A failure this adapter reports but did not cause.
``TypeError``, ``AttributeError`` and ``NameError`` are what a defect in
this adapter's own code raises, which is why the terminal-error classifier
reads them as ``ADAPTER_BUG``. They are also what an integrator's tool
raises, and what this adapter raises when it meets a value from outside it
that cannot be used. Raising this instead at the places that know the fault
came from outside keeps ``ADAPTER_BUG`` pointing at code the maintainer of
this adapter can actually fix.
It carries the original failure's text so the wire message is unchanged,
and the original exception as ``__cause__`` so the traceback still names
the real origin.
Constructing one is total. A wrapper that can raise while wrapping hands
the classifier the type it was built to suppress, which is the
misattribution this class exists to remove, so the text is read through
``_exception_text`` here rather than at each raise site.
"""
def __init__(self, cause: BaseException, prefix: str | None = None) -> None:
text = _exception_text(cause)
super().__init__(f"{prefix}: {text}" if prefix else text)
def _terminal_error_code(exc: BaseException) -> str:
"""The RUN_ERROR code for an exception that escaped a run loop.
``ADAPTER_BUG`` says the fault is in this adapter and sends the developer
reading it here rather than to the provider or the SDK, so it is claimed
only for the exception types a code defect raises AND only when nothing
upstream has established that the fault came from elsewhere. A
``_ForeignFault`` is that establishment: the SDK-stream boundary and the
serializer raise it for failures this adapter merely reported.
The claim is still made on exception type alone, so it is a claim and not
a proof. Adapter code that runs inside the Strands call (a registered hook,
a proxy tool) raises past that boundary and so is reported as a fault from
outside, which is the direction that costs a developer a wrong-looking code
rather than a wrong place to look.
"""
if isinstance(exc, (TypeError, AttributeError, NameError)):
return "ADAPTER_BUG"
return "STRANDS_ERROR"
async def _stream_with_model_context(
stream: AsyncIterator[Any], context_block: str
) -> AsyncIterator[Any]:
"""Scope request context to one model-stream pull at a time.
The FastAPI endpoint deliberately runs every ``__anext__`` call in a fresh
task so disconnect cancellation cannot interrupt agent cleanup. A
ContextVar token therefore cannot be held across an adapter yield: the
later reset may run in a different task context. Set and restore around
each pull instead, before yielding the resulting event to the endpoint.
This is also the one place both run loops pull the Strands stream through,
which makes it the boundary between this adapter's code and everything the
SDK runs for it: the model provider, the integrator's tools, the SDK
itself. A failure arriving from there is reported as a ``_ForeignFault`` so
an integrator's ``TypeError`` is not read as this adapter's defect.
``BaseException`` is deliberately not caught: cancellation and generator
close are not faults and must keep their own types.
"""
iterator = stream.__aiter__()
while True:
token = _MODEL_CONTEXT_BLOCK.set(context_block)
try:
event = await iterator.__anext__()
except StopAsyncIteration:
return
except Exception as exc:
raise _ForeignFault(exc) from exc
finally:
_MODEL_CONTEXT_BLOCK.reset(token)
yield event
# The shape a paused ``tool_context.interrupt()`` is answered with when the
# client cancels (``ResumeEntry.status == "cancelled"``) rather than resolving,
# so a generic tool can treat the pause as a denial. Adapter-managed approvals
# are answered ``{"approved": False}`` instead, and a frontend wait gets its own
# envelope, so this is the generic-interrupt shape rather than every cancel.
def _interrupt_cancelled() -> dict:
"""A fresh cancellation sentinel.
Built here rather than copied off the exported constant, so a consumer
mutating that constant cannot change what a paused tool receives. Compare
what a tool receives by value, never by identity.
The export stays a plain dict rather than a read-only proxy so that consumers
can still ``json.dumps`` it; nothing inside this module reads it.
"""
return {"cancelled": True}
INTERRUPT_CANCELLED = _interrupt_cancelled()
# Reserved native-interrupt name prefix for interrupts this adapter's approval
# hook raises. Anything else is a generic native interrupt. Reserved means
# reserved: an interrupt raised anywhere else under this prefix is classified,
# schema-checked and answered as an approval.
_TOOL_APPROVAL_NAME_PREFIX = "ag_ui:tool_call:"
def _strands_uses_presence_based_interrupt_responses(installed_version: str) -> bool:
"""Return the interrupt-response contract of a Strands SDK version."""
try:
major, minor = map(int, installed_version.split(".", 2)[:2])
except ValueError as exc:
raise RuntimeError(
"Cannot determine interrupt response semantics for "
f"strands-agents version {installed_version!r}"
) from exc
return (major, minor) >= (1, 19)
# Strands 1.15 through 1.18 returns a recorded response only when it is truthy.
# Version 1.19 changed that predicate to presence (``response is not None``).
_STRANDS_USES_PRESENCE_BASED_INTERRUPT_RESPONSES = (
_strands_uses_presence_based_interrupt_responses(
distribution_version("strands-agents")
)
)
def _tool_approval_response_schema() -> dict:
"""The response contract advertised for a tool-approval interrupt.
Single source for both the schema published on the AG-UI ``Interrupt`` and
the resume-payload validation, so a resume can still be checked when the
AG-UI bookkeeping did not survive a process restart.
"""
return {
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
}
def _is_tool_approval_interrupt(native_interrupt: Any) -> bool:
"""True when a native Strands interrupt came from the approval hook.
The reserved name prefix is the whole test. It also decides whether a resume
is answered raw or wrapped, so it deliberately does not additionally require
the reason: an approval whose reason did not survive a restart still has to
be answered in the shape its own hook reads.
"""
name = getattr(native_interrupt, "name", None)
return isinstance(name, str) and name.startswith(_TOOL_APPROVAL_NAME_PREFIX)
def _wrap_resume_response(status: str, payload: Any) -> dict:
"""Package a ``ResumeEntry`` for Strands' ``interruptResponse`` shape.
Supported Strands releases read a recorded answer either by truthiness
(1.15 through 1.18) or by presence (1.19+). Forwarding a raw falsy payload
can therefore re-raise the same interrupt and re-run the tool body on the
compatibility floor. Always hand Strands a truthy envelope; the tool
implementation unwraps it via ``.get("cancelled")`` / ``.get("response")``.
"""
if status == "cancelled":
return _interrupt_cancelled()
return {"response": payload}
def _frontend_tool_resume_content(entry: Any) -> tuple[str, bool]:
"""Return ``(content, is_error)`` for a frontend-wait ``ResumeEntry``.
The canonical payload is ``{"content": str, "error": bool}``; a bare string
or ``None`` is accepted as shorthand for a successful text result. A
``cancelled`` entry always reaches the tool as an error so the model sees a
refusal rather than an empty success.
"""
payload = entry.payload
content: Any = payload
is_error = entry.status == "cancelled"
if isinstance(payload, Mapping):
content = payload.get("content")
if not is_error:
is_error = bool(payload.get("error"))
if content is None:
content = "Tool call cancelled by the client." if is_error else ""
elif not isinstance(content, str):
content = json.dumps(content, default=str)
return content, is_error
def _native_resume_response(entry: Any, native_interrupt: Any) -> Any:
"""Return the answer Strands records when this entry is forwarded.
One definition, read both by the batch the run forwards and by the replay
comparison below, so the two cannot disagree about what was submitted.
"""
if _is_tool_approval_interrupt(native_interrupt):
# Only a resolved entry can grant, so a cancellation denies. Any other
# status would deny too, but cannot arrive: the wire type rejects it,
# since ``ResumeEntry.status`` admits only these two values. Stated as
# the reason rather than the forwarding site, which filters on only one
# of this function's three call paths. The TypeScript adapter reaches
# its equivalent guard the same way, and tests it, because its own types
# are structural rather than validated at the boundary.
return entry.payload if entry.status == "resolved" else {"approved": False}
if is_frontend_tool_interrupt(native_interrupt):
content, is_error = _frontend_tool_resume_content(entry)
return wrap_frontend_tool_response(content, is_error=is_error)
return _wrap_resume_response(entry.status, entry.payload)
def _legacy_resume_response(entry: Any, native_interrupt: Any) -> Any:
"""The answer the previous release recorded for this interrupt, or ``None``.
Only one interrupt changes shape across this release. A reserved-prefix
interrupt whose reason is not a mapping was classified generic before and is
classified as an approval now, so a checkpoint parked on it holds the generic
envelope while the replay comparison computes the raw approval answer. Left
unhandled, that thread never resumes: fresh input is refused because the
checkpoint is active, and the replay is refused because the shapes differ.
Deliberately narrow. ``None`` for every other interrupt, so nothing else
loosens: the envelope predates this release on this side, and a checkpoint
parked on anything else already holds the shape still computed for it.
"""
if not _is_tool_approval_interrupt(native_interrupt):
return None
if isinstance(getattr(native_interrupt, "reason", None), Mapping):
# The old classifier agreed this was an approval, so no shape moved.
return None
return _wrap_resume_response(
getattr(entry, "status", None), getattr(entry, "payload", None)
)
def _replays_recorded_answers(interrupt_state: Any, resume_entries: Any) -> bool:
"""True when this batch re-submits exactly the answers the checkpoint holds.
Strands records the submitted answers before it reruns hooks and the parked
tool execution, and clears the checkpoint only once that work succeeds. So a
hook failure, or a crash after session persistence, can restore a checkpoint
that is activated with every interrupt already answered. That thread has no
way forward: fresh input is refused because the checkpoint is active, and a
resume finds nothing open to address. Handing Strands the identical batch is
the way out, because it lets the SDK finish the parked execution. The
checkpoint itself must be left alone: clearing it would discard exactly that
parked execution. Anything short of an exact replay stays refused, with one
exception for a checkpoint parked by the previous release: see
``_legacy_resume_response``.
"""
recorded = getattr(interrupt_state, "interrupts", {}) or {}
if not recorded or len(resume_entries) != len(recorded):
return False
addressed: set[str] = set()
for entry in resume_entries:
interrupt_id = getattr(entry, "interrupt_id", None)
native_interrupt = recorded.get(interrupt_id)
if native_interrupt is None or interrupt_id in addressed:
return False
addressed.add(interrupt_id)
if not _native_interrupt_is_answered(native_interrupt):
return False
if native_interrupt.response == _native_resume_response(
entry, native_interrupt
):
continue
legacy = _legacy_resume_response(entry, native_interrupt)
if legacy is None or native_interrupt.response != legacy:
return False
return True
def _get_strands_session_manager(agent: Any) -> Any:
"""Return the agent's Strands ``SessionManager``, or ``None``.
Strands stores it publicly as ``session_manager``; some versions keep a
private ``_session_manager`` alias.
"""
return getattr(agent, "session_manager", None) or getattr(
agent, "_session_manager", None
)
def _plain_mapping(value: Any) -> Mapping:
"""Return ``value`` if it is a mapping, else an empty one."""
return value if isinstance(value, Mapping) else {}
def _detached_value(value: Any) -> Any:
"""A copy of any JSON-shaped value, detached at every depth.
The mapping form below is the common case; this one also takes a list, a
string or a number, which is what an unusable interrupt reason can be.
"""
try:
return deepcopy(value)
except Exception as exc:
# Saying so matters: the caller published this expecting a copy, and
# what it actually got is a handle on the live interrupt reason.
logger.warning(
"Could not detach an interrupt reason for publication; it is "
"shared with the live checkpoint: %s",
exc,
)
return value
def _detached_copy(value: Mapping) -> dict:
"""A copy of JSON-shaped data detached at every depth.
A shallow copy is not enough for anything published to a client: the nested
values would still be handles on the live native interrupt's reason. Falls
back to a shallow copy for the rare reason carrying something uncopyable,
which is still better than aliasing the whole mapping.
"""
try:
return deepcopy(dict(value))
except Exception as exc:
# A shallow copy still leaves the nested values shared, so this is a
# degraded result and not the guarantee the caller asked for.
logger.warning(
"Could not fully detach a tool input for publication; its nested "
"values are shared with the live checkpoint: %s",
exc,
)
return dict(value)
def _approval_tool_use_id(raw_reason: Any) -> Optional[str]:
"""The native tool use an approval is bound to, or ``None``.
Reported only when it is a usable string. ``Interrupt.tool_call_id`` is
typed ``Optional[str]``, so forwarding anything else would fail validation
and take down a run that could otherwise be approved.
"""
tool_use_id = _plain_mapping(raw_reason).get("tool_use_id")
return tool_use_id if isinstance(tool_use_id, str) and tool_use_id else None
def _approval_reason_fields(raw_reason: Any) -> tuple[str, dict]:
"""The tool identity an approval publishes, read out of its native reason.
The reason can be missing or malformed, most plausibly because it did not
survive a restart, so both fields fall back. The same defaults and the same
"is it usable?" tests as the TypeScript adapter, so an approval published
from either language reads identically.
"""
reason = _plain_mapping(raw_reason)
tool_name = reason.get("tool_name")
return (
tool_name if isinstance(tool_name, str) and tool_name else "unknown",
# Detached at every depth, not merely copied at the top: the published
# metadata must not be a handle on the live native interrupt's reason at
# ANY level. Same guarantee in TypeScript.
_detached_copy(_plain_mapping(reason.get("tool_input"))),
)
def _approval_metadata(
name: str, tool_name: str, tool_input: dict, raw_reason: Any
) -> dict:
"""The metadata an approval publishes.
``strandsName`` is camelCase among snake_case keys on purpose: ``metadata``
is a free-form dict, so no alias generator rewrites it, and the TypeScript
adapter publishes exactly this spelling. Renaming either side to look tidier
would reintroduce the divergence this contract exists to remove.
"""
metadata: dict = {
"tool_name": tool_name,
"tool_input": tool_input,
"strandsName": name,
}
# An approval whose reason carried nothing the three keys above could hold
# still publishes that reason, rather than reaching the client as nothing but
# the defaults. The test is what was actually extracted, not whether the
# reason was empty: a mapping like ``{"question": "..."}`` has keys and is
# still entirely unrepresented by tool_name / tool_input / tool_call_id.
# Detached like everything else published, since a reason can be a list or a
# nested mapping.
carried_nothing = (
tool_name == "unknown"
and not tool_input
and _approval_tool_use_id(raw_reason) is None
)
if raw_reason is not None and carried_nothing:
metadata["reason"] = _detached_value(raw_reason)
return metadata
def _strands_interrupt_to_agui(strands_interrupt: Any) -> "Interrupt":
"""Map a native Strands ``Interrupt`` onto an AG-UI ``Interrupt``.
Interrupts raised by this adapter's approval hook use its reserved
``ag_ui:tool_call:`` name prefix and map to AG-UI tool-call approvals.
All other native interrupts retain their generic name and reason payload.
"""
s_id = getattr(strands_interrupt, "id", "")
name = getattr(strands_interrupt, "name", None) or "interrupt"
raw_reason = getattr(strands_interrupt, "reason", None)
if _is_tool_approval_interrupt(strands_interrupt):
# An approval carries the same keys on both bridges, so a client renders
# one the same way whichever language served it. Two keys are
# conditional: ``tool_call_id``, which an approval raised without a
# native tool use has none of, and ``reason``, which is published only
# when nothing else carried it.
tool_name, tool_input = _approval_reason_fields(raw_reason)
return Interrupt(
id=s_id,
reason="tool_call",
message=f"Approve call to {tool_name}?",
tool_call_id=_approval_tool_use_id(raw_reason),
response_schema=_tool_approval_response_schema(),
metadata=_approval_metadata(name, tool_name, tool_input, raw_reason),
)
return Interrupt(
id=s_id,
reason=name,
message=None,
tool_call_id=None,
response_schema=None,
metadata={"reason": raw_reason} if raw_reason is not None else None,
)
def _native_interrupt_is_answered(interrupt: Any) -> bool:
"""True when this interrupt already carries an answer Strands will hand back.
Match the installed SDK's own ``ToolContext.interrupt`` predicate. Strands
1.15 through 1.18 uses truthiness; 1.19 and later uses presence, with
``None`` as the unanswered default.
"""
response = getattr(interrupt, "response", None)
if _STRANDS_USES_PRESENCE_BASED_INTERRUPT_RESPONSES:
return response is not None
return bool(response)
def _open_native_interrupts(interrupts: Any) -> dict:
"""Return the entries of ``interrupts`` still awaiting a human, keyed by id.
The native interrupt state is the only record of what is still in flight, and
every "is anything still open?" decision reads it through this one predicate,
so the pause this run reports and the resume the next one submits cannot
disagree and strand a client between them.
"""
return {
interrupt_id: interrupt
for interrupt_id, interrupt in (interrupts or {}).items()
if not _native_interrupt_is_answered(interrupt)
}
def _extract_interrupts(agent: Any, terminal_result: Any) -> Tuple[list, bool]:
"""Return the native Strands interrupts for a paused run, and whether the
run paused with nothing to report.
Prefers the terminal ``AgentResult`` (``stop_reason == "interrupt"`` with a
populated ``interrupts``); falls back to the live agent's
``_interrupt_state`` so a pause is still detected if the result event was
consumed by the stream's early-break path.
The second element is true only when the agent is demonstrably still parked
and there is nothing to hand the client: the checkpoint is active, every
interrupt on it reads as answered, and the terminal result says the run
stopped for an interrupt. That finish is indistinguishable from an ordinary
success in the event stream, so the only honest signal is the branch that
took it saying so, and the caller needs it because remembering such a resume
as completed would let a retry be answered from the idempotency fingerprint
without ever reaching the parked agent.
A stop reason on its own is not enough. A run reporting an interrupt with no
checkpoint left behind has finished its work, and treating that as a pause
would withhold the fingerprint from a resume that really did complete, which
costs the client its idempotent retry and leaves the answered interrupt
recorded as pending.
"""
stopped_for_interrupt = (
terminal_result is not None
and getattr(terminal_result, "stop_reason", None) == "interrupt"
)
if stopped_for_interrupt:
interrupts = getattr(terminal_result, "interrupts", None) or []
if interrupts:
return list(interrupts), False
interrupt_state = getattr(agent, "_interrupt_state", None)
if interrupt_state is not None and getattr(interrupt_state, "activated", False):
open_interrupts = _open_native_interrupts(
getattr(interrupt_state, "interrupts", {})
)
if not open_interrupts:
# The checkpoint is still activated yet every interrupt is answered
# under the installed SDK's semantics, so this run reports success
# while the agent may remain parked.
logger.debug(
"Native interrupt state is activated but every interrupt is "
"answered; reporting no pending interrupts"
)
return [], stopped_for_interrupt
return list(open_interrupts.values()), False
return [], False
# ``usage`` is optional because both of these are raised from two places: a
# preflight gate, which has no model call behind it, and the post-stream gate,
# which does.
def _interrupt_session_required_error(
usage: "List[TokenUsage] | None" = None,
) -> "RunErrorEvent":
return RunErrorEvent(
type=EventType.RUN_ERROR,
message=(
"A SessionManager is required for a mixed frontend-proxy/native "
"interrupt checkpoint"
),
code="INTERRUPT_SESSION_REQUIRED",
usage=usage,
)
def _interrupt_session_capability_error(
usage: "List[TokenUsage] | None" = None,
) -> "RunErrorEvent":
return RunErrorEvent(
type=EventType.RUN_ERROR,
message=(
"Mixed frontend-proxy/native interrupt state requires session_id, "
"a stable agent_id, and a session_repository exposing "
"list_messages() and update_message()"
),
code="INTERRUPT_SESSION_CAPABILITY_ERROR",
usage=usage,
)
def _interrupt_reconciliation_error() -> "RunErrorEvent":
return RunErrorEvent(
type=EventType.RUN_ERROR,
message="Active interrupt tool result reconciliation failed",
code="INTERRUPT_RECONCILIATION_ERROR",
)
def _interrupt_resume_error(message: str) -> "RunErrorEvent":
return RunErrorEvent(
type=EventType.RUN_ERROR,
message=message,
code="INTERRUPT_RESUME_ERROR",
)
CUSTOM_HOOK_ERROR = "hook_error"
CUSTOM_HOOK_ERROR_PROMPT_TOOL = "__prompt__"
def _hook_error(hook: str, tool: str, error: Exception) -> "CustomEvent":
"""Report a developer-supplied callback failure on the wire.
The event NAME and the payload KEYS mirror the TypeScript bridge exactly so
a client handles one shape across both languages. Three things about the
surrounding behaviour are deliberately not identical:
- ``hook`` carries each language's own spelling of the callback the
developer configured, so this reports ``state_from_args`` where
TypeScript reports ``stateFromArgs``. Emitting TypeScript's spelling here
would name a callback that does not exist in a Python config.