Skip to content

Commit 991b684

Browse files
authored
Merge branch 'main' into vasco/fix-subagent-registration-on-server
2 parents 528ffc2 + 3356db3 commit 991b684

File tree

8 files changed

+1637
-62
lines changed

8 files changed

+1637
-62
lines changed

.github/workflows/agent-server-rest-api-breakage.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ jobs:
3838
continue-on-error: ${{ !startsWith(github.head_ref, 'rel-') }}
3939
run: |
4040
uv run --with packaging python .github/scripts/check_agent_server_rest_api_breakage.py 2>&1 | tee api-breakage.log
41-
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
42-
exit "${PIPESTATUS[0]}"
41+
exit_code=${PIPESTATUS[0]}
42+
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
43+
exit "${exit_code}"
4344
4445
- name: Post REST API breakage report to PR
4546
if: ${{ always() && github.event_name == 'pull_request' }}

.github/workflows/api-breakage.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ jobs:
3030
continue-on-error: ${{ !startsWith(github.head_ref, 'rel-') }}
3131
run: |
3232
uv run python .github/scripts/check_sdk_api_breakage.py 2>&1 | tee api-breakage.log
33-
echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
34-
exit "${PIPESTATUS[0]}"
33+
exit_code=${PIPESTATUS[0]}
34+
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
35+
exit "${exit_code}"
3536
- name: Post API breakage report to PR
3637
if: ${{ always() && github.event_name == 'pull_request' }}
3738
uses: actions/github-script@v7
Lines changed: 202 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,227 @@
1-
"""Example: Loading Plugins via Conversation
1+
"""Example: Loading and Managing Plugins
22
3-
Demonstrates the recommended way to load plugins using the `plugins` parameter
4-
on Conversation. Plugins bundle skills, hooks, and MCP config together.
3+
This example demonstrates plugin loading and management in the SDK:
4+
5+
1. Loading plugins from GitHub via Conversation (PluginSource)
6+
2. Installing plugins to persistent storage (local and GitHub)
7+
3. Listing, loading, and uninstalling plugins
8+
9+
Plugins bundle skills, hooks, and MCP config together.
10+
11+
Supported plugin sources:
12+
- Local path: /path/to/plugin
13+
- GitHub shorthand: github:owner/repo
14+
- Git URL: https://github.com/owner/repo.git
15+
- With ref: branch, tag, or commit SHA
16+
- With repo_path: subdirectory for monorepos
517
618
For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins
719
"""
820

921
import os
10-
import sys
1122
import tempfile
1223
from pathlib import Path
1324

1425
from pydantic import SecretStr
1526

1627
from openhands.sdk import LLM, Agent, Conversation
17-
from openhands.sdk.plugin import PluginSource
28+
from openhands.sdk.plugin import (
29+
PluginFetchError,
30+
PluginSource,
31+
install_plugin,
32+
list_installed_plugins,
33+
load_installed_plugins,
34+
uninstall_plugin,
35+
)
1836
from openhands.sdk.tool import Tool
1937
from openhands.tools.file_editor import FileEditorTool
2038
from openhands.tools.terminal import TerminalTool
2139

2240

2341
# Locate example plugin directory
2442
script_dir = Path(__file__).parent
25-
plugin_path = script_dir / "example_plugins" / "code-quality"
26-
27-
# Define plugins to load
28-
# Supported sources: local path, "github:owner/repo", or git URL
29-
# Optional: ref (branch/tag/commit), repo_path (for monorepos)
30-
plugins = [
31-
PluginSource(source=str(plugin_path)),
32-
# PluginSource(source="github:org/security-plugin", ref="v2.0.0"),
33-
# PluginSource(source="github:org/monorepo", repo_path="plugins/logging"),
34-
]
35-
36-
# Check for API key
37-
api_key = os.getenv("LLM_API_KEY")
38-
if not api_key:
39-
print("Set LLM_API_KEY to run this example")
40-
print("EXAMPLE_COST: 0")
41-
sys.exit(0)
42-
43-
# Configure LLM and Agent
44-
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
45-
llm = LLM(
46-
usage_id="plugin-demo",
47-
model=model,
48-
api_key=SecretStr(api_key),
49-
base_url=os.getenv("LLM_BASE_URL"),
50-
)
51-
agent = Agent(
52-
llm=llm, tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)]
53-
)
43+
local_plugin_path = script_dir / "example_plugins" / "code-quality"
5444

55-
# Create conversation with plugins - skills, MCP config, and hooks are merged
56-
# Note: Plugins are loaded lazily on first send_message() or run() call
57-
with tempfile.TemporaryDirectory() as tmpdir:
58-
conversation = Conversation(
59-
agent=agent,
60-
workspace=tmpdir,
61-
plugins=plugins,
62-
)
6345

64-
# Test: The "lint" keyword triggers the python-linting skill
65-
# This first send_message() call triggers lazy plugin loading
66-
conversation.send_message("How do I lint Python code? Brief answer please.")
46+
def demo_conversation_with_github_plugin(llm: LLM) -> None:
47+
"""Demo 1: Load plugin from GitHub via Conversation.
48+
49+
This demonstrates loading a plugin directly from GitHub using PluginSource.
50+
The plugin is fetched and loaded lazily when the conversation starts.
51+
52+
We load the anthropics/skills repository which contains the "document-skills"
53+
plugin with skills for pptx, xlsx, docx, and pdf document processing.
54+
"""
55+
print("\n" + "=" * 60)
56+
print("DEMO 1: Loading plugin from GitHub via Conversation")
57+
print("=" * 60)
58+
59+
# Load the anthropics/skills repository which contains the document-skills plugin
60+
# This plugin bundles multiple document processing skills including pptx
61+
plugins = [
62+
PluginSource(
63+
source="github:anthropics/skills",
64+
ref="main",
65+
),
66+
]
6767

68-
# Verify skills were loaded from the plugin (after lazy loading)
69-
skills = (
70-
conversation.agent.agent_context.skills
71-
if conversation.agent.agent_context
72-
else []
68+
agent = Agent(
69+
llm=llm,
70+
tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)],
7371
)
74-
print(f"Loaded {len(skills)} skill(s) from plugins")
7572

76-
conversation.run()
73+
with tempfile.TemporaryDirectory() as tmpdir:
74+
try:
75+
conversation = Conversation(
76+
agent=agent,
77+
workspace=tmpdir,
78+
plugins=plugins,
79+
)
80+
81+
# Verify skills were loaded
82+
skills = (
83+
conversation.agent.agent_context.skills
84+
if conversation.agent.agent_context
85+
else []
86+
)
87+
print(f"✓ Loaded {len(skills)} skill(s) from GitHub plugin")
88+
for skill in skills:
89+
print(f" - {skill.name}")
90+
91+
# Ask a question that uses the pptx skill
92+
conversation.send_message(
93+
"What's the best way to create a PowerPoint presentation "
94+
"programmatically? Check the skill before you answer."
95+
)
96+
97+
conversation.run()
98+
99+
except PluginFetchError as e:
100+
print(f"⚠ Could not fetch from GitHub: {e}")
101+
print(" Skipping this demo (network or rate limiting issue)")
102+
103+
104+
def demo_install_local_plugin(installed_dir: Path) -> None:
105+
"""Demo 2: Install a plugin from a local path.
106+
107+
Useful for development or local-only plugins.
108+
"""
109+
print("\n" + "=" * 60)
110+
print("DEMO 2: Installing plugin from local path")
111+
print("=" * 60)
112+
113+
info = install_plugin(source=str(local_plugin_path), installed_dir=installed_dir)
114+
print(f"✓ Installed: {info.name} v{info.version}")
115+
print(f" Source: {info.source}")
116+
print(f" Path: {info.install_path}")
117+
118+
119+
def demo_install_github_plugin(installed_dir: Path) -> None:
120+
"""Demo 3: Install a plugin from GitHub to persistent storage.
121+
122+
Demonstrates loading the anthropics/skills repository which contains
123+
multiple document processing skills (pptx, xlsx, docx, pdf).
124+
"""
125+
print("\n" + "=" * 60)
126+
print("DEMO 3: Installing plugin from GitHub")
127+
print("=" * 60)
128+
129+
try:
130+
# Install the anthropics/skills repository (contains document-skills plugin)
131+
info = install_plugin(
132+
source="github:anthropics/skills",
133+
ref="main",
134+
installed_dir=installed_dir,
135+
)
136+
print(f"✓ Installed: {info.name} v{info.version}")
137+
print(f" Source: {info.source}")
138+
print(f" Resolved ref: {info.resolved_ref}")
139+
140+
# Show the skills loaded from the plugin
141+
plugins = load_installed_plugins(installed_dir=installed_dir)
142+
for plugin in plugins:
143+
if plugin.name == info.name:
144+
skills = plugin.get_all_skills()
145+
print(f" Skills: {len(skills)}")
146+
for skill in skills[:5]: # Show first 5 skills
147+
desc = skill.description or "(no description)"
148+
print(f" - {skill.name}: {desc[:50]}...")
149+
if len(skills) > 5:
150+
print(f" ... and {len(skills) - 5} more skills")
151+
152+
except PluginFetchError as e:
153+
print(f"⚠ Could not fetch from GitHub: {e}")
154+
print(" (Network or rate limiting issue)")
155+
156+
157+
def demo_list_and_load_plugins(installed_dir: Path) -> None:
158+
"""Demo 4: List and load installed plugins."""
159+
print("\n" + "=" * 60)
160+
print("DEMO 4: List and load installed plugins")
161+
print("=" * 60)
162+
163+
# List installed plugins
164+
print("Installed plugins:")
165+
for info in list_installed_plugins(installed_dir=installed_dir):
166+
print(f" - {info.name} v{info.version} ({info.source})")
167+
168+
# Load plugins as Plugin objects
169+
plugins = load_installed_plugins(installed_dir=installed_dir)
170+
print(f"\nLoaded {len(plugins)} plugin(s):")
171+
for plugin in plugins:
172+
skills = plugin.get_all_skills()
173+
print(f" - {plugin.name}: {len(skills)} skill(s)")
174+
175+
176+
def demo_uninstall_plugins(installed_dir: Path) -> None:
177+
"""Demo 5: Uninstall plugins."""
178+
print("\n" + "=" * 60)
179+
print("DEMO 5: Uninstalling plugins")
180+
print("=" * 60)
181+
182+
for info in list_installed_plugins(installed_dir=installed_dir):
183+
uninstall_plugin(info.name, installed_dir=installed_dir)
184+
print(f"✓ Uninstalled: {info.name}")
185+
186+
remaining = list_installed_plugins(installed_dir=installed_dir)
187+
print(f"\nRemaining plugins: {len(remaining)}")
188+
189+
190+
# Main execution
191+
if __name__ == "__main__":
192+
api_key = os.getenv("LLM_API_KEY")
193+
if not api_key:
194+
print("Set LLM_API_KEY to run the full example")
195+
print("Running install/uninstall demos only...")
196+
llm = None
197+
else:
198+
model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929")
199+
llm = LLM(
200+
usage_id="plugin-demo",
201+
model=model,
202+
api_key=SecretStr(api_key),
203+
base_url=os.getenv("LLM_BASE_URL"),
204+
)
205+
206+
with tempfile.TemporaryDirectory() as tmpdir:
207+
installed_dir = Path(tmpdir) / "installed"
208+
installed_dir.mkdir()
209+
210+
# Demo 1: Conversation with GitHub plugin (requires LLM)
211+
if llm:
212+
demo_conversation_with_github_plugin(llm)
213+
214+
# Demo 2-5: Plugin management (no LLM required)
215+
demo_install_local_plugin(installed_dir)
216+
demo_install_github_plugin(installed_dir)
217+
demo_list_and_load_plugins(installed_dir)
218+
demo_uninstall_plugins(installed_dir)
219+
220+
print("\n" + "=" * 60)
221+
print("EXAMPLE COMPLETED SUCCESSFULLY")
222+
print("=" * 60)
77223

78-
print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
224+
if llm:
225+
print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}")
226+
else:
227+
print("EXAMPLE_COST: 0")

openhands-sdk/openhands/sdk/conversation/state.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from openhands.sdk.conversation.persistence_const import BASE_STATE, EVENTS_DIR
1515
from openhands.sdk.conversation.secret_registry import SecretRegistry
1616
from openhands.sdk.conversation.types import ConversationCallbackType, ConversationID
17-
from openhands.sdk.event import ActionEvent, ObservationEvent, UserRejectObservation
17+
from openhands.sdk.event import (
18+
ActionEvent,
19+
AgentErrorEvent,
20+
ObservationEvent,
21+
UserRejectObservation,
22+
)
1823
from openhands.sdk.event.base import Event
1924
from openhands.sdk.event.types import EventID
2025
from openhands.sdk.io import FileStore, InMemoryFileStore, LocalFileStore
@@ -437,8 +442,12 @@ def get_unmatched_actions(events: Sequence[Event]) -> list[ActionEvent]:
437442
"""Find actions in the event history that don't have matching observations.
438443
439444
This method identifies ActionEvents that don't have corresponding
440-
ObservationEvents or UserRejectObservations, which typically indicates
441-
actions that are pending confirmation or execution.
445+
ObservationEvents, UserRejectObservations, or AgentErrorEvents,
446+
which typically indicates actions that are pending confirmation or execution.
447+
448+
Note: AgentErrorEvent is matched by tool_call_id (not action_id) because
449+
it doesn't have an action_id field. This is important for crash recovery
450+
scenarios where an error event is emitted after a server restart.
442451
443452
Args:
444453
events: List of events to search through
@@ -447,15 +456,24 @@ def get_unmatched_actions(events: Sequence[Event]) -> list[ActionEvent]:
447456
List of ActionEvent objects that don't have corresponding observations,
448457
in chronological order
449458
"""
450-
observed_action_ids = set()
459+
observed_action_ids: set[EventID] = set()
460+
observed_tool_call_ids: set[str] = set()
451461
unmatched_actions = []
452462
# Search in reverse - recent events are more likely to be unmatched
453463
for event in reversed(events):
454464
if isinstance(event, (ObservationEvent, UserRejectObservation)):
455465
observed_action_ids.add(event.action_id)
466+
elif isinstance(event, AgentErrorEvent):
467+
# AgentErrorEvent doesn't have action_id, match by tool_call_id
468+
observed_tool_call_ids.add(event.tool_call_id)
456469
elif isinstance(event, ActionEvent):
457470
# Only executable actions (validated) are considered pending
458-
if event.action is not None and event.id not in observed_action_ids:
471+
# Check both action_id and tool_call_id for matching
472+
if (
473+
event.action is not None
474+
and event.id not in observed_action_ids
475+
and event.tool_call_id not in observed_tool_call_ids
476+
):
459477
# Insert at beginning to maintain chronological order in result
460478
unmatched_actions.insert(0, event)
461479

0 commit comments

Comments
 (0)