openhands-sdk 1.0.0__tar.gz

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 (152) hide show
  1. openhands_sdk-1.0.0/PKG-INFO +16 -0
  2. openhands_sdk-1.0.0/openhands/sdk/__init__.py +97 -0
  3. openhands_sdk-1.0.0/openhands/sdk/agent/__init__.py +8 -0
  4. openhands_sdk-1.0.0/openhands/sdk/agent/agent.py +456 -0
  5. openhands_sdk-1.0.0/openhands/sdk/agent/base.py +420 -0
  6. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  7. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  8. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  9. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  10. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/system_prompt.j2 +113 -0
  11. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  12. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  13. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  14. openhands_sdk-1.0.0/openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  15. openhands_sdk-1.0.0/openhands/sdk/context/__init__.py +26 -0
  16. openhands_sdk-1.0.0/openhands/sdk/context/agent_context.py +177 -0
  17. openhands_sdk-1.0.0/openhands/sdk/context/condenser/__init__.py +18 -0
  18. openhands_sdk-1.0.0/openhands/sdk/context/condenser/base.py +95 -0
  19. openhands_sdk-1.0.0/openhands/sdk/context/condenser/llm_summarizing_condenser.py +84 -0
  20. openhands_sdk-1.0.0/openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  21. openhands_sdk-1.0.0/openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  22. openhands_sdk-1.0.0/openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  23. openhands_sdk-1.0.0/openhands/sdk/context/prompts/__init__.py +6 -0
  24. openhands_sdk-1.0.0/openhands/sdk/context/prompts/prompt.py +74 -0
  25. openhands_sdk-1.0.0/openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  26. openhands_sdk-1.0.0/openhands/sdk/context/prompts/templates/system_message_suffix.j2 +16 -0
  27. openhands_sdk-1.0.0/openhands/sdk/context/skills/__init__.py +24 -0
  28. openhands_sdk-1.0.0/openhands/sdk/context/skills/exceptions.py +11 -0
  29. openhands_sdk-1.0.0/openhands/sdk/context/skills/skill.py +359 -0
  30. openhands_sdk-1.0.0/openhands/sdk/context/skills/trigger.py +36 -0
  31. openhands_sdk-1.0.0/openhands/sdk/context/skills/types.py +48 -0
  32. openhands_sdk-1.0.0/openhands/sdk/context/view.py +243 -0
  33. openhands_sdk-1.0.0/openhands/sdk/conversation/__init__.py +32 -0
  34. openhands_sdk-1.0.0/openhands/sdk/conversation/base.py +213 -0
  35. openhands_sdk-1.0.0/openhands/sdk/conversation/conversation.py +123 -0
  36. openhands_sdk-1.0.0/openhands/sdk/conversation/conversation_stats.py +101 -0
  37. openhands_sdk-1.0.0/openhands/sdk/conversation/event_store.py +157 -0
  38. openhands_sdk-1.0.0/openhands/sdk/conversation/events_list_base.py +17 -0
  39. openhands_sdk-1.0.0/openhands/sdk/conversation/exceptions.py +25 -0
  40. openhands_sdk-1.0.0/openhands/sdk/conversation/fifo_lock.py +127 -0
  41. openhands_sdk-1.0.0/openhands/sdk/conversation/impl/__init__.py +5 -0
  42. openhands_sdk-1.0.0/openhands/sdk/conversation/impl/local_conversation.py +436 -0
  43. openhands_sdk-1.0.0/openhands/sdk/conversation/impl/remote_conversation.py +678 -0
  44. openhands_sdk-1.0.0/openhands/sdk/conversation/persistence_const.py +9 -0
  45. openhands_sdk-1.0.0/openhands/sdk/conversation/response_utils.py +41 -0
  46. openhands_sdk-1.0.0/openhands/sdk/conversation/secret_registry.py +131 -0
  47. openhands_sdk-1.0.0/openhands/sdk/conversation/secret_source.py +86 -0
  48. openhands_sdk-1.0.0/openhands/sdk/conversation/serialization_diff.py +0 -0
  49. openhands_sdk-1.0.0/openhands/sdk/conversation/state.py +336 -0
  50. openhands_sdk-1.0.0/openhands/sdk/conversation/stuck_detector.py +275 -0
  51. openhands_sdk-1.0.0/openhands/sdk/conversation/title_utils.py +191 -0
  52. openhands_sdk-1.0.0/openhands/sdk/conversation/types.py +10 -0
  53. openhands_sdk-1.0.0/openhands/sdk/conversation/visualizer/__init__.py +12 -0
  54. openhands_sdk-1.0.0/openhands/sdk/conversation/visualizer/base.py +86 -0
  55. openhands_sdk-1.0.0/openhands/sdk/conversation/visualizer/default.py +318 -0
  56. openhands_sdk-1.0.0/openhands/sdk/event/__init__.py +38 -0
  57. openhands_sdk-1.0.0/openhands/sdk/event/base.py +149 -0
  58. openhands_sdk-1.0.0/openhands/sdk/event/condenser.py +71 -0
  59. openhands_sdk-1.0.0/openhands/sdk/event/conversation_state.py +96 -0
  60. openhands_sdk-1.0.0/openhands/sdk/event/llm_convertible/__init__.py +20 -0
  61. openhands_sdk-1.0.0/openhands/sdk/event/llm_convertible/action.py +139 -0
  62. openhands_sdk-1.0.0/openhands/sdk/event/llm_convertible/message.py +135 -0
  63. openhands_sdk-1.0.0/openhands/sdk/event/llm_convertible/observation.py +141 -0
  64. openhands_sdk-1.0.0/openhands/sdk/event/llm_convertible/system.py +65 -0
  65. openhands_sdk-1.0.0/openhands/sdk/event/types.py +11 -0
  66. openhands_sdk-1.0.0/openhands/sdk/event/user_action.py +21 -0
  67. openhands_sdk-1.0.0/openhands/sdk/git/exceptions.py +43 -0
  68. openhands_sdk-1.0.0/openhands/sdk/git/git_changes.py +249 -0
  69. openhands_sdk-1.0.0/openhands/sdk/git/git_diff.py +129 -0
  70. openhands_sdk-1.0.0/openhands/sdk/git/models.py +21 -0
  71. openhands_sdk-1.0.0/openhands/sdk/git/utils.py +189 -0
  72. openhands_sdk-1.0.0/openhands/sdk/io/__init__.py +6 -0
  73. openhands_sdk-1.0.0/openhands/sdk/io/base.py +48 -0
  74. openhands_sdk-1.0.0/openhands/sdk/io/local.py +82 -0
  75. openhands_sdk-1.0.0/openhands/sdk/io/memory.py +54 -0
  76. openhands_sdk-1.0.0/openhands/sdk/llm/__init__.py +42 -0
  77. openhands_sdk-1.0.0/openhands/sdk/llm/exceptions/__init__.py +45 -0
  78. openhands_sdk-1.0.0/openhands/sdk/llm/exceptions/classifier.py +50 -0
  79. openhands_sdk-1.0.0/openhands/sdk/llm/exceptions/mapping.py +54 -0
  80. openhands_sdk-1.0.0/openhands/sdk/llm/exceptions/types.py +101 -0
  81. openhands_sdk-1.0.0/openhands/sdk/llm/llm.py +1095 -0
  82. openhands_sdk-1.0.0/openhands/sdk/llm/llm_registry.py +153 -0
  83. openhands_sdk-1.0.0/openhands/sdk/llm/llm_response.py +59 -0
  84. openhands_sdk-1.0.0/openhands/sdk/llm/message.py +585 -0
  85. openhands_sdk-1.0.0/openhands/sdk/llm/mixins/fn_call_converter.py +1050 -0
  86. openhands_sdk-1.0.0/openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  87. openhands_sdk-1.0.0/openhands/sdk/llm/options/__init__.py +1 -0
  88. openhands_sdk-1.0.0/openhands/sdk/llm/options/chat_options.py +87 -0
  89. openhands_sdk-1.0.0/openhands/sdk/llm/options/common.py +19 -0
  90. openhands_sdk-1.0.0/openhands/sdk/llm/options/responses_options.py +57 -0
  91. openhands_sdk-1.0.0/openhands/sdk/llm/router/__init__.py +10 -0
  92. openhands_sdk-1.0.0/openhands/sdk/llm/router/base.py +114 -0
  93. openhands_sdk-1.0.0/openhands/sdk/llm/router/impl/multimodal.py +76 -0
  94. openhands_sdk-1.0.0/openhands/sdk/llm/router/impl/random.py +22 -0
  95. openhands_sdk-1.0.0/openhands/sdk/llm/utils/metrics.py +312 -0
  96. openhands_sdk-1.0.0/openhands/sdk/llm/utils/model_features.py +114 -0
  97. openhands_sdk-1.0.0/openhands/sdk/llm/utils/retry_mixin.py +123 -0
  98. openhands_sdk-1.0.0/openhands/sdk/llm/utils/telemetry.py +317 -0
  99. openhands_sdk-1.0.0/openhands/sdk/llm/utils/unverified_models.py +156 -0
  100. openhands_sdk-1.0.0/openhands/sdk/llm/utils/verified_models.py +61 -0
  101. openhands_sdk-1.0.0/openhands/sdk/logger/__init__.py +22 -0
  102. openhands_sdk-1.0.0/openhands/sdk/logger/logger.py +188 -0
  103. openhands_sdk-1.0.0/openhands/sdk/logger/rolling.py +113 -0
  104. openhands_sdk-1.0.0/openhands/sdk/mcp/__init__.py +21 -0
  105. openhands_sdk-1.0.0/openhands/sdk/mcp/client.py +76 -0
  106. openhands_sdk-1.0.0/openhands/sdk/mcp/definition.py +106 -0
  107. openhands_sdk-1.0.0/openhands/sdk/mcp/tool.py +271 -0
  108. openhands_sdk-1.0.0/openhands/sdk/mcp/utils.py +63 -0
  109. openhands_sdk-1.0.0/openhands/sdk/observability/__init__.py +4 -0
  110. openhands_sdk-1.0.0/openhands/sdk/observability/laminar.py +166 -0
  111. openhands_sdk-1.0.0/openhands/sdk/observability/utils.py +20 -0
  112. openhands_sdk-1.0.0/openhands/sdk/py.typed +0 -0
  113. openhands_sdk-1.0.0/openhands/sdk/security/__init__.py +6 -0
  114. openhands_sdk-1.0.0/openhands/sdk/security/analyzer.py +111 -0
  115. openhands_sdk-1.0.0/openhands/sdk/security/confirmation_policy.py +61 -0
  116. openhands_sdk-1.0.0/openhands/sdk/security/llm_analyzer.py +29 -0
  117. openhands_sdk-1.0.0/openhands/sdk/security/risk.py +100 -0
  118. openhands_sdk-1.0.0/openhands/sdk/tool/__init__.py +34 -0
  119. openhands_sdk-1.0.0/openhands/sdk/tool/builtins/__init__.py +34 -0
  120. openhands_sdk-1.0.0/openhands/sdk/tool/builtins/finish.py +106 -0
  121. openhands_sdk-1.0.0/openhands/sdk/tool/builtins/think.py +117 -0
  122. openhands_sdk-1.0.0/openhands/sdk/tool/registry.py +161 -0
  123. openhands_sdk-1.0.0/openhands/sdk/tool/schema.py +276 -0
  124. openhands_sdk-1.0.0/openhands/sdk/tool/spec.py +39 -0
  125. openhands_sdk-1.0.0/openhands/sdk/tool/tool.py +464 -0
  126. openhands_sdk-1.0.0/openhands/sdk/utils/__init__.py +14 -0
  127. openhands_sdk-1.0.0/openhands/sdk/utils/async_executor.py +106 -0
  128. openhands_sdk-1.0.0/openhands/sdk/utils/async_utils.py +39 -0
  129. openhands_sdk-1.0.0/openhands/sdk/utils/cipher.py +68 -0
  130. openhands_sdk-1.0.0/openhands/sdk/utils/command.py +90 -0
  131. openhands_sdk-1.0.0/openhands/sdk/utils/json.py +48 -0
  132. openhands_sdk-1.0.0/openhands/sdk/utils/models.py +302 -0
  133. openhands_sdk-1.0.0/openhands/sdk/utils/pydantic_diff.py +85 -0
  134. openhands_sdk-1.0.0/openhands/sdk/utils/pydantic_secrets.py +64 -0
  135. openhands_sdk-1.0.0/openhands/sdk/utils/truncate.py +44 -0
  136. openhands_sdk-1.0.0/openhands/sdk/utils/visualize.py +23 -0
  137. openhands_sdk-1.0.0/openhands/sdk/workspace/__init__.py +15 -0
  138. openhands_sdk-1.0.0/openhands/sdk/workspace/base.py +143 -0
  139. openhands_sdk-1.0.0/openhands/sdk/workspace/local.py +183 -0
  140. openhands_sdk-1.0.0/openhands/sdk/workspace/models.py +29 -0
  141. openhands_sdk-1.0.0/openhands/sdk/workspace/remote/__init__.py +8 -0
  142. openhands_sdk-1.0.0/openhands/sdk/workspace/remote/async_remote_workspace.py +128 -0
  143. openhands_sdk-1.0.0/openhands/sdk/workspace/remote/base.py +149 -0
  144. openhands_sdk-1.0.0/openhands/sdk/workspace/remote/remote_workspace_mixin.py +321 -0
  145. openhands_sdk-1.0.0/openhands/sdk/workspace/workspace.py +49 -0
  146. openhands_sdk-1.0.0/openhands_sdk.egg-info/PKG-INFO +16 -0
  147. openhands_sdk-1.0.0/openhands_sdk.egg-info/SOURCES.txt +294 -0
  148. openhands_sdk-1.0.0/openhands_sdk.egg-info/dependency_links.txt +1 -0
  149. openhands_sdk-1.0.0/openhands_sdk.egg-info/requires.txt +12 -0
  150. openhands_sdk-1.0.0/openhands_sdk.egg-info/top_level.txt +1 -0
  151. openhands_sdk-1.0.0/pyproject.toml +34 -0
  152. openhands_sdk-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-sdk
3
+ Version: 1.0.0
4
+ Summary: OpenHands SDK - Core functionality for building AI agents
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastmcp>=2.11.3
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: litellm>=1.77.7.dev9
9
+ Requires-Dist: pydantic>=2.11.7
10
+ Requires-Dist: python-frontmatter>=1.1.0
11
+ Requires-Dist: python-json-logger>=3.3.0
12
+ Requires-Dist: tenacity>=9.1.2
13
+ Requires-Dist: websockets>=12
14
+ Requires-Dist: lmnr>=0.7.20
15
+ Provides-Extra: boto3
16
+ Requires-Dist: boto3>=1.35.0; extra == "boto3"
@@ -0,0 +1,97 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from openhands.sdk.agent import Agent, AgentBase
4
+ from openhands.sdk.context import AgentContext
5
+ from openhands.sdk.context.condenser import (
6
+ LLMSummarizingCondenser,
7
+ )
8
+ from openhands.sdk.conversation import (
9
+ BaseConversation,
10
+ Conversation,
11
+ ConversationCallbackType,
12
+ LocalConversation,
13
+ RemoteConversation,
14
+ )
15
+ from openhands.sdk.conversation.conversation_stats import ConversationStats
16
+ from openhands.sdk.event import Event, LLMConvertibleEvent
17
+ from openhands.sdk.event.llm_convertible import MessageEvent
18
+ from openhands.sdk.io import FileStore, LocalFileStore
19
+ from openhands.sdk.llm import (
20
+ LLM,
21
+ ImageContent,
22
+ LLMRegistry,
23
+ Message,
24
+ RedactedThinkingBlock,
25
+ RegistryEvent,
26
+ TextContent,
27
+ ThinkingBlock,
28
+ )
29
+ from openhands.sdk.logger import get_logger
30
+ from openhands.sdk.mcp import (
31
+ MCPClient,
32
+ MCPToolDefinition,
33
+ MCPToolObservation,
34
+ create_mcp_tools,
35
+ )
36
+ from openhands.sdk.tool import (
37
+ Action,
38
+ Observation,
39
+ Tool,
40
+ ToolDefinition,
41
+ list_registered_tools,
42
+ register_tool,
43
+ resolve_tool,
44
+ )
45
+ from openhands.sdk.workspace import (
46
+ LocalWorkspace,
47
+ RemoteWorkspace,
48
+ Workspace,
49
+ )
50
+
51
+
52
+ try:
53
+ __version__ = version("openhands-sdk")
54
+ except PackageNotFoundError:
55
+ __version__ = "0.0.0" # fallback for editable/unbuilt environments
56
+
57
+ __all__ = [
58
+ "LLM",
59
+ "LLMRegistry",
60
+ "ConversationStats",
61
+ "RegistryEvent",
62
+ "Message",
63
+ "TextContent",
64
+ "ImageContent",
65
+ "ThinkingBlock",
66
+ "RedactedThinkingBlock",
67
+ "Tool",
68
+ "ToolDefinition",
69
+ "AgentBase",
70
+ "Agent",
71
+ "Action",
72
+ "Observation",
73
+ "MCPClient",
74
+ "MCPToolDefinition",
75
+ "MCPToolObservation",
76
+ "MessageEvent",
77
+ "create_mcp_tools",
78
+ "get_logger",
79
+ "Conversation",
80
+ "BaseConversation",
81
+ "LocalConversation",
82
+ "RemoteConversation",
83
+ "ConversationCallbackType",
84
+ "Event",
85
+ "LLMConvertibleEvent",
86
+ "AgentContext",
87
+ "LLMSummarizingCondenser",
88
+ "FileStore",
89
+ "LocalFileStore",
90
+ "register_tool",
91
+ "resolve_tool",
92
+ "list_registered_tools",
93
+ "Workspace",
94
+ "LocalWorkspace",
95
+ "RemoteWorkspace",
96
+ "__version__",
97
+ ]
@@ -0,0 +1,8 @@
1
+ from openhands.sdk.agent.agent import Agent
2
+ from openhands.sdk.agent.base import AgentBase
3
+
4
+
5
+ __all__ = [
6
+ "Agent",
7
+ "AgentBase",
8
+ ]
@@ -0,0 +1,456 @@
1
+ import json
2
+
3
+ from pydantic import ValidationError
4
+
5
+ import openhands.sdk.security.risk as risk
6
+ from openhands.sdk.agent.base import AgentBase
7
+ from openhands.sdk.context.view import View
8
+ from openhands.sdk.conversation import (
9
+ ConversationCallbackType,
10
+ ConversationState,
11
+ LocalConversation,
12
+ )
13
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
14
+ from openhands.sdk.event import (
15
+ ActionEvent,
16
+ AgentErrorEvent,
17
+ LLMConvertibleEvent,
18
+ MessageEvent,
19
+ ObservationEvent,
20
+ SystemPromptEvent,
21
+ )
22
+ from openhands.sdk.event.condenser import Condensation, CondensationRequest
23
+ from openhands.sdk.llm import (
24
+ Message,
25
+ MessageToolCall,
26
+ ReasoningItemModel,
27
+ RedactedThinkingBlock,
28
+ TextContent,
29
+ ThinkingBlock,
30
+ )
31
+ from openhands.sdk.llm.exceptions import (
32
+ FunctionCallValidationError,
33
+ LLMContextWindowExceedError,
34
+ )
35
+ from openhands.sdk.logger import get_logger
36
+ from openhands.sdk.observability.laminar import (
37
+ maybe_init_laminar,
38
+ observe,
39
+ should_enable_observability,
40
+ )
41
+ from openhands.sdk.observability.utils import extract_action_name
42
+ from openhands.sdk.security.confirmation_policy import NeverConfirm
43
+ from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
44
+ from openhands.sdk.tool import (
45
+ Action,
46
+ Observation,
47
+ )
48
+ from openhands.sdk.tool.builtins import (
49
+ FinishAction,
50
+ FinishTool,
51
+ ThinkAction,
52
+ )
53
+
54
+
55
+ logger = get_logger(__name__)
56
+ maybe_init_laminar()
57
+
58
+
59
+ class Agent(AgentBase):
60
+ """Main agent implementation for OpenHands.
61
+
62
+ The Agent class provides the core functionality for running AI agents that can
63
+ interact with tools, process messages, and execute actions. It inherits from
64
+ AgentBase and implements the agent execution logic.
65
+
66
+ Example:
67
+ >>> from openhands.sdk import LLM, Agent, Tool
68
+ >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
69
+ >>> tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")]
70
+ >>> agent = Agent(llm=llm, tools=tools)
71
+ """
72
+
73
+ @property
74
+ def _add_security_risk_prediction(self) -> bool:
75
+ return isinstance(self.security_analyzer, LLMSecurityAnalyzer)
76
+
77
+ def init_state(
78
+ self,
79
+ state: ConversationState,
80
+ on_event: ConversationCallbackType,
81
+ ) -> None:
82
+ super().init_state(state, on_event=on_event)
83
+ # TODO(openhands): we should add test to test this init_state will actually
84
+ # modify state in-place
85
+
86
+ # Validate security analyzer configuration once during initialization
87
+ if self._add_security_risk_prediction and isinstance(
88
+ state.confirmation_policy, NeverConfirm
89
+ ):
90
+ # If security analyzer is enabled, we always need a policy that is not
91
+ # NeverConfirm, otherwise we are just predicting risks without using them,
92
+ # and waste tokens!
93
+ logger.warning(
94
+ "LLM security analyzer is enabled but confirmation "
95
+ "policy is set to NeverConfirm"
96
+ )
97
+
98
+ llm_convertible_messages = [
99
+ event for event in state.events if isinstance(event, LLMConvertibleEvent)
100
+ ]
101
+ if len(llm_convertible_messages) == 0:
102
+ # Prepare system message
103
+ event = SystemPromptEvent(
104
+ source="agent",
105
+ system_prompt=TextContent(text=self.system_message),
106
+ tools=[
107
+ t.to_openai_tool(
108
+ add_security_risk_prediction=self._add_security_risk_prediction
109
+ )
110
+ for t in self.tools_map.values()
111
+ ],
112
+ )
113
+ on_event(event)
114
+
115
+ def _execute_actions(
116
+ self,
117
+ conversation: LocalConversation,
118
+ action_events: list[ActionEvent],
119
+ on_event: ConversationCallbackType,
120
+ ):
121
+ for action_event in action_events:
122
+ self._execute_action_event(conversation, action_event, on_event=on_event)
123
+
124
+ @observe(name="agent.step", ignore_inputs=["state", "on_event"])
125
+ def step(
126
+ self,
127
+ conversation: LocalConversation,
128
+ on_event: ConversationCallbackType,
129
+ ) -> None:
130
+ state = conversation.state
131
+ # Check for pending actions (implicit confirmation)
132
+ # and execute them before sampling new actions.
133
+ pending_actions = ConversationState.get_unmatched_actions(state.events)
134
+ if pending_actions:
135
+ logger.info(
136
+ "Confirmation mode: Executing %d pending action(s)",
137
+ len(pending_actions),
138
+ )
139
+ self._execute_actions(conversation, pending_actions, on_event)
140
+ return
141
+
142
+ # If a condenser is registered with the agent, we need to give it an
143
+ # opportunity to transform the events. This will either produce a list
144
+ # of events, exactly as expected, or a new condensation that needs to be
145
+ # processed before the agent can sample another action.
146
+ if self.condenser is not None:
147
+ view = View.from_events(state.events)
148
+ condensation_result = self.condenser.condense(view)
149
+
150
+ match condensation_result:
151
+ case View():
152
+ llm_convertible_events = condensation_result.events
153
+
154
+ case Condensation():
155
+ on_event(condensation_result)
156
+ return None
157
+
158
+ else:
159
+ llm_convertible_events = [
160
+ e for e in state.events if isinstance(e, LLMConvertibleEvent)
161
+ ]
162
+
163
+ # Get LLM Response (Action)
164
+ _messages = LLMConvertibleEvent.events_to_messages(llm_convertible_events)
165
+ logger.debug(
166
+ "Sending messages to LLM: "
167
+ f"{json.dumps([m.model_dump() for m in _messages[1:]], indent=2)}"
168
+ )
169
+
170
+ try:
171
+ if self.llm.uses_responses_api():
172
+ llm_response = self.llm.responses(
173
+ messages=_messages,
174
+ tools=list(self.tools_map.values()),
175
+ include=None,
176
+ store=False,
177
+ add_security_risk_prediction=self._add_security_risk_prediction,
178
+ extra_body=self.llm.litellm_extra_body,
179
+ )
180
+ else:
181
+ llm_response = self.llm.completion(
182
+ messages=_messages,
183
+ tools=list(self.tools_map.values()),
184
+ extra_body=self.llm.litellm_extra_body,
185
+ add_security_risk_prediction=self._add_security_risk_prediction,
186
+ )
187
+ except FunctionCallValidationError as e:
188
+ logger.warning(f"LLM generated malformed function call: {e}")
189
+ error_message = MessageEvent(
190
+ source="user",
191
+ llm_message=Message(
192
+ role="user",
193
+ content=[TextContent(text=str(e))],
194
+ ),
195
+ )
196
+ on_event(error_message)
197
+ return
198
+ except LLMContextWindowExceedError:
199
+ # If condenser is available and handles requests, trigger condensation
200
+ if (
201
+ self.condenser is not None
202
+ and self.condenser.handles_condensation_requests()
203
+ ):
204
+ logger.warning(
205
+ "LLM raised context window exceeded error, triggering condensation"
206
+ )
207
+ on_event(CondensationRequest())
208
+ return
209
+ # No condenser available; re-raise for client handling
210
+ raise
211
+
212
+ # LLMResponse already contains the converted message and metrics snapshot
213
+ message: Message = llm_response.message
214
+
215
+ if message.tool_calls and len(message.tool_calls) > 0:
216
+ if not all(isinstance(c, TextContent) for c in message.content):
217
+ logger.warning(
218
+ "LLM returned tool calls but message content is not all "
219
+ "TextContent - ignoring non-text content"
220
+ )
221
+
222
+ # Generate unique batch ID for this LLM response
223
+ thought_content = [c for c in message.content if isinstance(c, TextContent)]
224
+
225
+ action_events: list[ActionEvent] = []
226
+ for i, tool_call in enumerate(message.tool_calls):
227
+ action_event = self._get_action_event(
228
+ tool_call,
229
+ llm_response_id=llm_response.id,
230
+ on_event=on_event,
231
+ thought=thought_content
232
+ if i == 0
233
+ else [], # Only first gets thought
234
+ # Only first gets reasoning content
235
+ reasoning_content=message.reasoning_content if i == 0 else None,
236
+ # Only first gets thinking blocks
237
+ thinking_blocks=list(message.thinking_blocks) if i == 0 else [],
238
+ responses_reasoning_item=message.responses_reasoning_item
239
+ if i == 0
240
+ else None,
241
+ )
242
+ if action_event is None:
243
+ continue
244
+ action_events.append(action_event)
245
+
246
+ # Handle confirmation mode - exit early if actions need confirmation
247
+ if self._requires_user_confirmation(state, action_events):
248
+ return
249
+
250
+ if action_events:
251
+ self._execute_actions(conversation, action_events, on_event)
252
+
253
+ else:
254
+ logger.info("LLM produced a message response - awaits user input")
255
+ state.execution_status = ConversationExecutionStatus.FINISHED
256
+ msg_event = MessageEvent(
257
+ source="agent",
258
+ llm_message=message,
259
+ llm_response_id=llm_response.id,
260
+ )
261
+ on_event(msg_event)
262
+
263
+ def _requires_user_confirmation(
264
+ self, state: ConversationState, action_events: list[ActionEvent]
265
+ ) -> bool:
266
+ """
267
+ Decide whether user confirmation is needed to proceed.
268
+
269
+ Rules:
270
+ 1. Confirmation mode is enabled
271
+ 2. Every action requires confirmation
272
+ 3. A single `FinishAction` never requires confirmation
273
+ 4. A single `ThinkAction` never requires confirmation
274
+ """
275
+ # A single `FinishAction` or `ThinkAction` never requires confirmation
276
+ if len(action_events) == 1 and isinstance(
277
+ action_events[0].action, (FinishAction, ThinkAction)
278
+ ):
279
+ return False
280
+
281
+ # If there are no actions there is nothing to confirm
282
+ if len(action_events) == 0:
283
+ return False
284
+
285
+ # If a security analyzer is registered, use it to grab the risks of the actions
286
+ # involved. If not, we'll set the risks to UNKNOWN.
287
+ if self.security_analyzer is not None:
288
+ risks = [
289
+ risk
290
+ for _, risk in self.security_analyzer.analyze_pending_actions(
291
+ action_events
292
+ )
293
+ ]
294
+ else:
295
+ risks = [risk.SecurityRisk.UNKNOWN] * len(action_events)
296
+
297
+ # Grab the confirmation policy from the state and pass in the risks.
298
+ if any(state.confirmation_policy.should_confirm(risk) for risk in risks):
299
+ state.execution_status = (
300
+ ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
301
+ )
302
+ return True
303
+
304
+ return False
305
+
306
+ def _get_action_event(
307
+ self,
308
+ tool_call: MessageToolCall,
309
+ llm_response_id: str,
310
+ on_event: ConversationCallbackType,
311
+ thought: list[TextContent] = [],
312
+ reasoning_content: str | None = None,
313
+ thinking_blocks: list[ThinkingBlock | RedactedThinkingBlock] = [],
314
+ responses_reasoning_item: ReasoningItemModel | None = None,
315
+ ) -> ActionEvent | None:
316
+ """Converts a tool call into an ActionEvent, validating arguments.
317
+
318
+ NOTE: state will be mutated in-place.
319
+ """
320
+ tool_name = tool_call.name
321
+ tool = self.tools_map.get(tool_name, None)
322
+ # Handle non-existing tools
323
+ if tool is None:
324
+ available = list(self.tools_map.keys())
325
+ err = f"Tool '{tool_name}' not found. Available: {available}"
326
+ logger.error(err)
327
+ # Persist assistant function_call so next turn has matching call_id
328
+ tc_event = ActionEvent(
329
+ source="agent",
330
+ thought=thought,
331
+ reasoning_content=reasoning_content,
332
+ thinking_blocks=thinking_blocks,
333
+ responses_reasoning_item=responses_reasoning_item,
334
+ tool_call=tool_call,
335
+ tool_name=tool_call.name,
336
+ tool_call_id=tool_call.id,
337
+ llm_response_id=llm_response_id,
338
+ action=None,
339
+ )
340
+ on_event(tc_event)
341
+ event = AgentErrorEvent(
342
+ error=err,
343
+ tool_name=tool_name,
344
+ tool_call_id=tool_call.id,
345
+ )
346
+ on_event(event)
347
+ return
348
+
349
+ # Validate arguments
350
+ security_risk: risk.SecurityRisk = risk.SecurityRisk.UNKNOWN
351
+ try:
352
+ arguments = json.loads(tool_call.arguments)
353
+
354
+ # if the tool has a security_risk field (when security analyzer is set),
355
+ # pop it out as it's not part of the tool's action schema
356
+ if (
357
+ _predicted_risk := arguments.pop("security_risk", None)
358
+ ) is not None and self.security_analyzer is not None:
359
+ try:
360
+ security_risk = risk.SecurityRisk(_predicted_risk)
361
+ except ValueError:
362
+ logger.warning(
363
+ f"Invalid security_risk value from LLM: {_predicted_risk}"
364
+ )
365
+
366
+ assert "security_risk" not in arguments, (
367
+ "Unexpected 'security_risk' key found in tool arguments"
368
+ )
369
+
370
+ action: Action = tool.action_from_arguments(arguments)
371
+ except (json.JSONDecodeError, ValidationError) as e:
372
+ err = (
373
+ f"Error validating args {tool_call.arguments} for tool "
374
+ f"'{tool.name}': {e}"
375
+ )
376
+ # Persist assistant function_call so next turn has matching call_id
377
+ tc_event = ActionEvent(
378
+ source="agent",
379
+ thought=thought,
380
+ reasoning_content=reasoning_content,
381
+ thinking_blocks=thinking_blocks,
382
+ responses_reasoning_item=responses_reasoning_item,
383
+ tool_call=tool_call,
384
+ tool_name=tool_call.name,
385
+ tool_call_id=tool_call.id,
386
+ llm_response_id=llm_response_id,
387
+ action=None,
388
+ )
389
+ on_event(tc_event)
390
+ event = AgentErrorEvent(
391
+ error=err,
392
+ tool_name=tool_name,
393
+ tool_call_id=tool_call.id,
394
+ )
395
+ on_event(event)
396
+ return
397
+
398
+ action_event = ActionEvent(
399
+ action=action,
400
+ thought=thought,
401
+ reasoning_content=reasoning_content,
402
+ thinking_blocks=thinking_blocks,
403
+ responses_reasoning_item=responses_reasoning_item,
404
+ tool_name=tool.name,
405
+ tool_call_id=tool_call.id,
406
+ tool_call=tool_call,
407
+ llm_response_id=llm_response_id,
408
+ security_risk=security_risk,
409
+ )
410
+ on_event(action_event)
411
+ return action_event
412
+
413
+ @observe(ignore_inputs=["state", "on_event"])
414
+ def _execute_action_event(
415
+ self,
416
+ conversation: LocalConversation,
417
+ action_event: ActionEvent,
418
+ on_event: ConversationCallbackType,
419
+ ):
420
+ """Execute an action event and update the conversation state.
421
+
422
+ It will call the tool's executor and update the state & call callback fn
423
+ with the observation.
424
+ """
425
+ state = conversation.state
426
+ tool = self.tools_map.get(action_event.tool_name, None)
427
+ if tool is None:
428
+ raise RuntimeError(
429
+ f"Tool '{action_event.tool_name}' not found. This should not happen "
430
+ "as it was checked earlier."
431
+ )
432
+
433
+ # Execute actions!
434
+ if should_enable_observability():
435
+ tool_name = extract_action_name(action_event)
436
+ observation: Observation = observe(name=tool_name, span_type="TOOL")(tool)(
437
+ action_event.action, conversation
438
+ )
439
+ else:
440
+ observation = tool(action_event.action, conversation)
441
+ assert isinstance(observation, Observation), (
442
+ f"Tool '{tool.name}' executor must return an Observation"
443
+ )
444
+
445
+ obs_event = ObservationEvent(
446
+ observation=observation,
447
+ action_id=action_event.id,
448
+ tool_name=tool.name,
449
+ tool_call_id=action_event.tool_call.id,
450
+ )
451
+ on_event(obs_event)
452
+
453
+ # Set conversation state
454
+ if tool.name == FinishTool.name:
455
+ state.execution_status = ConversationExecutionStatus.FINISHED
456
+ return obs_event