openhands-sdk 1.8.2__py3-none-any.whl → 1.9.1__py3-none-any.whl

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 (32) hide show
  1. openhands/sdk/agent/agent.py +64 -0
  2. openhands/sdk/agent/base.py +22 -10
  3. openhands/sdk/context/skills/skill.py +59 -1
  4. openhands/sdk/context/skills/utils.py +6 -65
  5. openhands/sdk/conversation/base.py +5 -0
  6. openhands/sdk/conversation/impl/remote_conversation.py +16 -3
  7. openhands/sdk/conversation/visualizer/base.py +23 -0
  8. openhands/sdk/critic/__init__.py +4 -1
  9. openhands/sdk/critic/base.py +17 -20
  10. openhands/sdk/critic/impl/__init__.py +2 -0
  11. openhands/sdk/critic/impl/agent_finished.py +9 -5
  12. openhands/sdk/critic/impl/api/__init__.py +18 -0
  13. openhands/sdk/critic/impl/api/chat_template.py +232 -0
  14. openhands/sdk/critic/impl/api/client.py +313 -0
  15. openhands/sdk/critic/impl/api/critic.py +90 -0
  16. openhands/sdk/critic/impl/api/taxonomy.py +180 -0
  17. openhands/sdk/critic/result.py +148 -0
  18. openhands/sdk/event/llm_convertible/action.py +10 -0
  19. openhands/sdk/event/llm_convertible/message.py +10 -0
  20. openhands/sdk/git/cached_repo.py +459 -0
  21. openhands/sdk/git/utils.py +118 -3
  22. openhands/sdk/hooks/__init__.py +7 -1
  23. openhands/sdk/hooks/config.py +154 -45
  24. openhands/sdk/llm/utils/model_features.py +3 -0
  25. openhands/sdk/plugin/__init__.py +17 -0
  26. openhands/sdk/plugin/fetch.py +231 -0
  27. openhands/sdk/plugin/plugin.py +61 -4
  28. openhands/sdk/plugin/types.py +394 -1
  29. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/METADATA +5 -1
  30. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/RECORD +32 -24
  31. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/WHEEL +1 -1
  32. {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.1.dist-info}/top_level.txt +0 -0
@@ -17,6 +17,7 @@ from openhands.sdk.conversation import (
17
17
  LocalConversation,
18
18
  )
19
19
  from openhands.sdk.conversation.state import ConversationExecutionStatus
20
+ from openhands.sdk.critic.base import CriticResult
20
21
  from openhands.sdk.event import (
21
22
  ActionEvent,
22
23
  AgentErrorEvent,
@@ -120,6 +121,48 @@ class Agent(AgentBase):
120
121
  )
121
122
  on_event(event)
122
123
 
124
+ def _should_evaluate_with_critic(self, action: Action | None) -> bool:
125
+ """Determine if critic should evaluate based on action type and mode."""
126
+ if self.critic is None:
127
+ return False
128
+
129
+ if self.critic.mode == "all_actions":
130
+ return True
131
+
132
+ # For "finish_and_message" mode, only evaluate FinishAction
133
+ # (MessageEvent will be handled separately in step())
134
+ if isinstance(action, FinishAction):
135
+ return True
136
+
137
+ return False
138
+
139
+ def _evaluate_with_critic(
140
+ self, conversation: LocalConversation, event: ActionEvent | MessageEvent
141
+ ) -> CriticResult | None:
142
+ """Run critic evaluation on the current event and history."""
143
+ if self.critic is None:
144
+ return None
145
+
146
+ try:
147
+ # Build event history including the current event
148
+ events = list(conversation.state.events) + [event]
149
+ llm_convertible_events = [
150
+ e for e in events if isinstance(e, LLMConvertibleEvent)
151
+ ]
152
+
153
+ # Evaluate without git_patch for now
154
+ critic_result = self.critic.evaluate(
155
+ events=llm_convertible_events, git_patch=None
156
+ )
157
+ logger.info(
158
+ f"✓ Critic evaluation: score={critic_result.score:.3f}, "
159
+ f"success={critic_result.success}"
160
+ )
161
+ return critic_result
162
+ except Exception as e:
163
+ logger.error(f"✗ Critic evaluation failed: {e}", exc_info=True)
164
+ return None
165
+
123
166
  def _execute_actions(
124
167
  self,
125
168
  conversation: LocalConversation,
@@ -237,6 +280,7 @@ class Agent(AgentBase):
237
280
  for i, tool_call in enumerate(message.tool_calls):
238
281
  action_event = self._get_action_event(
239
282
  tool_call,
283
+ conversation=conversation,
240
284
  llm_response_id=llm_response.id,
241
285
  on_event=on_event,
242
286
  security_analyzer=state.security_analyzer,
@@ -275,6 +319,14 @@ class Agent(AgentBase):
275
319
  llm_message=message,
276
320
  llm_response_id=llm_response.id,
277
321
  )
322
+ # Run critic evaluation if configured for finish_and_message mode
323
+ if self.critic is not None and self.critic.mode == "finish_and_message":
324
+ critic_result = self._evaluate_with_critic(conversation, msg_event)
325
+ if critic_result is not None:
326
+ # Create new event with critic result
327
+ msg_event = msg_event.model_copy(
328
+ update={"critic_result": critic_result}
329
+ )
278
330
  on_event(msg_event)
279
331
 
280
332
  # Emit VLLM token ids if enabled
@@ -389,6 +441,7 @@ class Agent(AgentBase):
389
441
  def _get_action_event(
390
442
  self,
391
443
  tool_call: MessageToolCall,
444
+ conversation: LocalConversation,
392
445
  llm_response_id: str,
393
446
  on_event: ConversationCallbackType,
394
447
  security_analyzer: analyzer.SecurityAnalyzerBase | None = None,
@@ -477,6 +530,7 @@ class Agent(AgentBase):
477
530
  on_event(event)
478
531
  return
479
532
 
533
+ # Create initial action event
480
534
  action_event = ActionEvent(
481
535
  action=action,
482
536
  thought=thought or [],
@@ -490,6 +544,16 @@ class Agent(AgentBase):
490
544
  security_risk=security_risk,
491
545
  summary=summary,
492
546
  )
547
+
548
+ # Run critic evaluation if configured
549
+ if self._should_evaluate_with_critic(action):
550
+ critic_result = self._evaluate_with_critic(conversation, action_event)
551
+ if critic_result is not None:
552
+ # Create new event with critic result
553
+ action_event = action_event.model_copy(
554
+ update={"critic_result": critic_result}
555
+ )
556
+
493
557
  on_event(action_event)
494
558
  return action_event
495
559
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  import re
3
5
  import sys
@@ -16,6 +18,7 @@ from pydantic import (
16
18
  from openhands.sdk.context.agent_context import AgentContext
17
19
  from openhands.sdk.context.condenser import CondenserBase
18
20
  from openhands.sdk.context.prompts.prompt import render_template
21
+ from openhands.sdk.critic.base import CriticBase
19
22
  from openhands.sdk.llm import LLM
20
23
  from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
21
24
  from openhands.sdk.logger import get_logger
@@ -37,7 +40,6 @@ if TYPE_CHECKING:
37
40
  ConversationTokenCallbackType,
38
41
  )
39
42
 
40
-
41
43
  logger = get_logger(__name__)
42
44
 
43
45
 
@@ -174,6 +176,16 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
174
176
  ],
175
177
  )
176
178
 
179
+ critic: CriticBase | None = Field(
180
+ default=None,
181
+ description=(
182
+ "EXPERIMENTAL: Optional critic to evaluate agent actions and messages "
183
+ "in real-time. API and behavior may change without notice. "
184
+ "May impact performance, especially in 'all_actions' mode."
185
+ ),
186
+ examples=[{"kind": "AgentFinishedCritic"}],
187
+ )
188
+
177
189
  # Runtime materialized tools; private and non-serializable
178
190
  _tools: dict[str, ToolDefinition] = PrivateAttr(default_factory=dict)
179
191
  _initialized: bool = PrivateAttr(default=False)
@@ -226,8 +238,8 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
226
238
 
227
239
  def init_state(
228
240
  self,
229
- state: "ConversationState",
230
- on_event: "ConversationCallbackType", # noqa: ARG002
241
+ state: ConversationState,
242
+ on_event: ConversationCallbackType, # noqa: ARG002
231
243
  ) -> None:
232
244
  """Initialize the empty conversation state to prepare the agent for user
233
245
  messages.
@@ -238,7 +250,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
238
250
  """
239
251
  self._initialize(state)
240
252
 
241
- def _initialize(self, state: "ConversationState"):
253
+ def _initialize(self, state: ConversationState):
242
254
  """Create an AgentBase instance from an AgentSpec."""
243
255
 
244
256
  if self._initialized:
@@ -310,9 +322,9 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
310
322
  @abstractmethod
311
323
  def step(
312
324
  self,
313
- conversation: "LocalConversation",
314
- on_event: "ConversationCallbackType",
315
- on_token: "ConversationTokenCallbackType | None" = None,
325
+ conversation: LocalConversation,
326
+ on_event: ConversationCallbackType,
327
+ on_token: ConversationTokenCallbackType | None = None,
316
328
  ) -> None:
317
329
  """Taking a step in the conversation.
318
330
 
@@ -332,9 +344,9 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
332
344
 
333
345
  def verify(
334
346
  self,
335
- persisted: "AgentBase",
336
- events: "Sequence[Any] | None" = None,
337
- ) -> "AgentBase":
347
+ persisted: AgentBase,
348
+ events: Sequence[Any] | None = None,
349
+ ) -> AgentBase:
338
350
  """Verify that we can resume this agent from persisted state.
339
351
 
340
352
  This PR's goal is to *not* reconcile configuration between persisted and
@@ -1,7 +1,7 @@
1
1
  import io
2
2
  import re
3
3
  from pathlib import Path
4
- from typing import Annotated, ClassVar, Union
4
+ from typing import Annotated, ClassVar, Literal, Union
5
5
  from xml.sax.saxutils import escape as xml_escape
6
6
 
7
7
  import frontmatter
@@ -37,6 +37,22 @@ logger = get_logger(__name__)
37
37
  THIRD_PARTY_SKILL_MAX_CHARS = 10_000
38
38
 
39
39
 
40
+ class SkillInfo(BaseModel):
41
+ """Lightweight representation of a skill's essential information.
42
+
43
+ This class provides a standardized, serializable format for skill metadata
44
+ that can be used across different components of the system.
45
+ """
46
+
47
+ name: str
48
+ type: Literal["repo", "knowledge", "agentskills"]
49
+ content: str
50
+ triggers: list[str] = Field(default_factory=list)
51
+ source: str | None = None
52
+ description: str | None = None
53
+ is_agentskills_format: bool = False
54
+
55
+
40
56
  class SkillResources(BaseModel):
41
57
  """Resource directories for a skill (AgentSkills standard).
42
58
 
@@ -560,6 +576,48 @@ class Skill(BaseModel):
560
576
  logger.debug(f"This skill requires user input: {variables}")
561
577
  return len(variables) > 0
562
578
 
579
+ def get_skill_type(self) -> Literal["repo", "knowledge", "agentskills"]:
580
+ """Determine the type of this skill.
581
+
582
+ Returns:
583
+ "agentskills" for AgentSkills format, "repo" for always-active skills,
584
+ "knowledge" for trigger-based skills.
585
+ """
586
+ if self.is_agentskills_format:
587
+ return "agentskills"
588
+ elif self.trigger is None:
589
+ return "repo"
590
+ else:
591
+ return "knowledge"
592
+
593
+ def get_triggers(self) -> list[str]:
594
+ """Extract trigger keywords from this skill.
595
+
596
+ Returns:
597
+ List of trigger strings, or empty list if no triggers.
598
+ """
599
+ if isinstance(self.trigger, KeywordTrigger):
600
+ return self.trigger.keywords
601
+ elif isinstance(self.trigger, TaskTrigger):
602
+ return self.trigger.triggers
603
+ return []
604
+
605
+ def to_skill_info(self) -> SkillInfo:
606
+ """Convert this skill to a SkillInfo.
607
+
608
+ Returns:
609
+ SkillInfo containing the skill's essential information.
610
+ """
611
+ return SkillInfo(
612
+ name=self.name,
613
+ type=self.get_skill_type(),
614
+ content=self.content,
615
+ triggers=self.get_triggers(),
616
+ source=self.source,
617
+ description=self.description,
618
+ is_agentskills_format=self.is_agentskills_format,
619
+ )
620
+
563
621
 
564
622
  def load_skills_from_dir(
565
623
  skill_dir: str | Path,
@@ -5,14 +5,13 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import re
8
- import shutil
9
- import subprocess
10
8
  from pathlib import Path
11
9
  from typing import TYPE_CHECKING
12
10
 
13
11
  from fastmcp.mcp_config import MCPConfig
14
12
 
15
13
  from openhands.sdk.context.skills.exceptions import SkillValidationError
14
+ from openhands.sdk.git.cached_repo import try_cached_clone_or_update
16
15
  from openhands.sdk.logger import get_logger
17
16
 
18
17
 
@@ -316,77 +315,19 @@ def update_skills_repository(
316
315
  ) -> Path | None:
317
316
  """Clone or update the local skills repository.
318
317
 
318
+ Uses the shared git caching infrastructure from openhands.sdk.git.cached_repo.
319
+ When updating, performs: fetch -> checkout ref -> reset --hard to origin/ref.
320
+
319
321
  Args:
320
322
  repo_url: URL of the skills repository.
321
- branch: Branch name to use.
323
+ branch: Branch name to checkout and track.
322
324
  cache_dir: Directory where the repository should be cached.
323
325
 
324
326
  Returns:
325
327
  Path to the local repository if successful, None otherwise.
326
328
  """
327
329
  repo_path = cache_dir / "public-skills"
328
-
329
- try:
330
- if repo_path.exists() and (repo_path / ".git").exists():
331
- logger.debug(f"Updating skills repository at {repo_path}")
332
- try:
333
- subprocess.run(
334
- ["git", "fetch", "origin"],
335
- cwd=repo_path,
336
- check=True,
337
- capture_output=True,
338
- timeout=30,
339
- )
340
- subprocess.run(
341
- ["git", "reset", "--hard", f"origin/{branch}"],
342
- cwd=repo_path,
343
- check=True,
344
- capture_output=True,
345
- timeout=10,
346
- )
347
- logger.debug("Skills repository updated successfully")
348
- except subprocess.TimeoutExpired:
349
- logger.warning("Git pull timed out, using existing cached repository")
350
- except subprocess.CalledProcessError as e:
351
- logger.warning(
352
- f"Failed to update repository: {e.stderr.decode()}, "
353
- f"using existing cached version"
354
- )
355
- else:
356
- logger.info(f"Cloning public skills repository from {repo_url}")
357
- if repo_path.exists():
358
- shutil.rmtree(repo_path)
359
-
360
- subprocess.run(
361
- [
362
- "git",
363
- "clone",
364
- "--depth",
365
- "1",
366
- "--branch",
367
- branch,
368
- repo_url,
369
- str(repo_path),
370
- ],
371
- check=True,
372
- capture_output=True,
373
- timeout=60,
374
- )
375
- logger.debug(f"Skills repository cloned to {repo_path}")
376
-
377
- return repo_path
378
-
379
- except subprocess.TimeoutExpired:
380
- logger.warning(f"Git operation timed out for {repo_url}")
381
- return None
382
- except subprocess.CalledProcessError as e:
383
- logger.warning(
384
- f"Failed to clone/update repository {repo_url}: {e.stderr.decode()}"
385
- )
386
- return None
387
- except Exception as e:
388
- logger.warning(f"Error managing skills repository: {str(e)}")
389
- return None
330
+ return try_cached_clone_or_update(repo_url, repo_path, ref=branch, update=True)
390
331
 
391
332
 
392
333
  def discover_skill_resources(skill_dir: Path) -> SkillResources:
@@ -162,6 +162,11 @@ class BaseConversation(ABC):
162
162
  """Set the confirmation policy for the conversation."""
163
163
  ...
164
164
 
165
+ @abstractmethod
166
+ def set_security_analyzer(self, analyzer: SecurityAnalyzerBase | None) -> None:
167
+ """Set the security analyzer for the conversation."""
168
+ ...
169
+
165
170
  @property
166
171
  def confirmation_policy_active(self) -> bool:
167
172
  return not isinstance(self.state.confirmation_policy, NeverConfirm)
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import bisect
2
3
  import json
3
4
  import os
4
5
  import threading
@@ -219,13 +220,25 @@ class RemoteEventsList(EventsListBase):
219
220
  logger.debug(f"Full sync completed, {len(events)} events cached")
220
221
 
221
222
  def add_event(self, event: Event) -> None:
222
- """Add a new event to the local cache (called by WebSocket callback)."""
223
+ """Add a new event to the local cache (called by WebSocket callback).
224
+
225
+ Events are inserted in sorted order by timestamp to maintain correct
226
+ temporal ordering regardless of WebSocket delivery order.
227
+ """
223
228
  with self._lock:
224
229
  # Check if event already exists to avoid duplicates
225
230
  if event.id not in self._cached_event_ids:
226
- self._cached_events.append(event)
231
+ # Use bisect with key function for O(log N) insertion
232
+ # This ensures events are always ordered correctly even if
233
+ # WebSocket delivers them out of order
234
+ insert_pos = bisect.bisect_right(
235
+ self._cached_events, event.timestamp, key=lambda e: e.timestamp
236
+ )
237
+ self._cached_events.insert(insert_pos, event)
227
238
  self._cached_event_ids.add(event.id)
228
- logger.debug(f"Added event {event.id} to local cache")
239
+ logger.debug(
240
+ f"Added event {event.id} to local cache at position {insert_pos}"
241
+ )
229
242
 
230
243
  def append(self, event: Event) -> None:
231
244
  """Add a new event to the list (for compatibility with EventLog interface)."""
@@ -65,3 +65,26 @@ class ConversationVisualizerBase(ABC):
65
65
  event: The event to visualize
66
66
  """
67
67
  pass
68
+
69
+ def create_sub_visualizer(
70
+ self,
71
+ agent_id: str, # noqa: ARG002
72
+ ) -> "ConversationVisualizerBase | None":
73
+ """Create a visualizer for a sub-agent during delegation.
74
+
75
+ Override this method to support sub-agent visualization in multi-agent
76
+ delegation scenarios. The sub-visualizer will be used to display events
77
+ from the spawned sub-agent.
78
+
79
+ By default, returns None which means sub-agents will not have visualization.
80
+ Subclasses that support delegation (like DelegationVisualizer) should
81
+ override this method to create appropriate sub-visualizers.
82
+
83
+ Args:
84
+ agent_id: The identifier of the sub-agent being spawned
85
+
86
+ Returns:
87
+ A visualizer instance for the sub-agent, or None if sub-agent
88
+ visualization is not supported
89
+ """
90
+ return None
@@ -1,15 +1,18 @@
1
- from openhands.sdk.critic.base import CriticBase, CriticResult
1
+ from openhands.sdk.critic.base import CriticBase
2
2
  from openhands.sdk.critic.impl import (
3
3
  AgentFinishedCritic,
4
+ APIBasedCritic,
4
5
  EmptyPatchCritic,
5
6
  PassCritic,
6
7
  )
8
+ from openhands.sdk.critic.result import CriticResult
7
9
 
8
10
 
9
11
  __all__ = [
10
12
  "CriticBase",
11
13
  "CriticResult",
12
14
  "AgentFinishedCritic",
15
+ "APIBasedCritic",
13
16
  "EmptyPatchCritic",
14
17
  "PassCritic",
15
18
  ]
@@ -1,29 +1,15 @@
1
1
  import abc
2
2
  from collections.abc import Sequence
3
- from typing import ClassVar
3
+ from typing import TYPE_CHECKING, Literal
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import Field
6
6
 
7
- from openhands.sdk.event import LLMConvertibleEvent
7
+ from openhands.sdk.critic.result import CriticResult
8
8
  from openhands.sdk.utils.models import DiscriminatedUnionMixin
9
9
 
10
10
 
11
- class CriticResult(BaseModel):
12
- """A critic result is a score and a message."""
13
-
14
- THRESHOLD: ClassVar[float] = 0.5
15
-
16
- score: float = Field(
17
- description="A predicted probability of success between 0 and 1.",
18
- ge=0.0,
19
- le=1.0,
20
- )
21
- message: str | None = Field(description="An optional message explaining the score.")
22
-
23
- @property
24
- def success(self) -> bool:
25
- """Whether the agent is successful."""
26
- return self.score >= CriticResult.THRESHOLD
11
+ if TYPE_CHECKING:
12
+ from openhands.sdk.event.base import LLMConvertibleEvent
27
13
 
28
14
 
29
15
  class CriticBase(DiscriminatedUnionMixin, abc.ABC):
@@ -31,8 +17,19 @@ class CriticBase(DiscriminatedUnionMixin, abc.ABC):
31
17
  optional git patch, and returns a score about the quality of agent's action.
32
18
  """
33
19
 
20
+ mode: Literal["finish_and_message", "all_actions"] = Field(
21
+ default="finish_and_message",
22
+ description=(
23
+ "When to run critic evaluation:\n"
24
+ "- 'finish_and_message': Evaluate on FinishAction and agent"
25
+ " MessageEvent (default, minimal performance impact)\n"
26
+ "- 'all_actions': Evaluate after every agent action (WARNING: "
27
+ "significantly slower due to API calls on each action)"
28
+ ),
29
+ )
30
+
34
31
  @abc.abstractmethod
35
32
  def evaluate(
36
- self, events: Sequence[LLMConvertibleEvent], git_patch: str | None = None
33
+ self, events: Sequence["LLMConvertibleEvent"], git_patch: str | None = None
37
34
  ) -> CriticResult:
38
35
  pass
@@ -1,12 +1,14 @@
1
1
  """Critic implementations module."""
2
2
 
3
3
  from openhands.sdk.critic.impl.agent_finished import AgentFinishedCritic
4
+ from openhands.sdk.critic.impl.api import APIBasedCritic
4
5
  from openhands.sdk.critic.impl.empty_patch import EmptyPatchCritic
5
6
  from openhands.sdk.critic.impl.pass_critic import PassCritic
6
7
 
7
8
 
8
9
  __all__ = [
9
10
  "AgentFinishedCritic",
11
+ "APIBasedCritic",
10
12
  "EmptyPatchCritic",
11
13
  "PassCritic",
12
14
  ]
@@ -7,13 +7,17 @@ This critic evaluates whether an agent properly finished a task by checking:
7
7
  """
8
8
 
9
9
  from collections.abc import Sequence
10
+ from typing import TYPE_CHECKING
10
11
 
11
12
  from openhands.sdk.critic.base import CriticBase, CriticResult
12
- from openhands.sdk.event import ActionEvent, LLMConvertibleEvent
13
13
  from openhands.sdk.logger import get_logger
14
14
  from openhands.sdk.tool.builtins.finish import FinishAction
15
15
 
16
16
 
17
+ if TYPE_CHECKING:
18
+ from openhands.sdk.event.base import LLMConvertibleEvent
19
+
20
+
17
21
  logger = get_logger(__name__)
18
22
 
19
23
 
@@ -27,7 +31,7 @@ class AgentFinishedCritic(CriticBase):
27
31
  """
28
32
 
29
33
  def evaluate(
30
- self, events: Sequence[LLMConvertibleEvent], git_patch: str | None = None
34
+ self, events: Sequence["LLMConvertibleEvent"], git_patch: str | None = None
31
35
  ) -> CriticResult:
32
36
  """
33
37
  Evaluate if an agent properly finished with a non-empty git patch.
@@ -66,18 +70,18 @@ class AgentFinishedCritic(CriticBase):
66
70
  message="Agent completed with FinishAction and non-empty patch",
67
71
  )
68
72
 
69
- def _has_finish_action(self, events: Sequence[LLMConvertibleEvent]) -> bool:
73
+ def _has_finish_action(self, events: Sequence["LLMConvertibleEvent"]) -> bool:
70
74
  """Check if the last action was a FinishAction."""
71
75
  if not events:
72
76
  return False
73
77
 
74
78
  # Look for the last ActionEvent in the history
79
+ from openhands.sdk.event.llm_convertible.action import ActionEvent
80
+
75
81
  for event in reversed(events):
76
82
  if isinstance(event, ActionEvent):
77
- # Check if this is a FinishAction
78
83
  if event.action and isinstance(event.action, FinishAction):
79
84
  return True
80
- # If we find any other action type, the agent didn't finish
81
85
  return False
82
86
 
83
87
  return False
@@ -0,0 +1,18 @@
1
+ from openhands.sdk.critic.impl.api.client import (
2
+ ClassificationItem,
3
+ ClassificationResponse,
4
+ CriticClient,
5
+ LabelProbMap,
6
+ UsageTokens,
7
+ )
8
+ from openhands.sdk.critic.impl.api.critic import APIBasedCritic
9
+
10
+
11
+ __all__ = [
12
+ "APIBasedCritic",
13
+ "CriticClient",
14
+ "ClassificationItem",
15
+ "ClassificationResponse",
16
+ "LabelProbMap",
17
+ "UsageTokens",
18
+ ]