Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/precommit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v6
Expand All @@ -27,5 +29,10 @@ jobs:
- name: Install dependencies
run: uv sync --frozen --group dev

- name: Ensure dynamic attribute baseline only shrinks
run: >-
uv run python scripts/check_forbidden_dynamic_attributes.py
--baseline-ref ${{ github.event.pull_request.base.sha || github.event.before }}

- name: Run pre-commit (all files)
run: uv run pre-commit run --all-files --show-diff-on-failure
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ repos:
pass_filenames: true
always_run: false
- id: check-forbidden-dynamic-attributes
name: Forbid getattr and setattr in SDK
name: Forbid dynamic attribute access in SDK
entry: uv run python scripts/check_forbidden_dynamic_attributes.py
language: system
files: ^openhands-sdk/.*\.py$
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ async def validate_profile(

llm = await asyncio.to_thread(create_subscription_llm_from_config, llm)

# Mirror the runtime dispatch (see ``amake_llm_completion``) and stay
# Mirror the runtime dispatch (see ``LLM.agenerate``) and stay
# async so provider I/O doesn't pin the FastAPI event loop.
if llm.uses_responses_api():
await llm.aresponses(messages=messages, max_tokens=1)
Expand Down
20 changes: 10 additions & 10 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@
)
from openhands.sdk.agent.stream_context import StreamContext
from openhands.sdk.agent.utils import (
amake_llm_completion,
aprepare_llm_messages,
fix_malformed_tool_arguments,
make_llm_completion,
normalize_tool_call,
parse_tool_call_arguments,
prepare_llm_messages,
Expand Down Expand Up @@ -536,7 +534,7 @@ def init_state(
system_prompt=TextContent(text=self.static_system_message),
# Tools are stored as ToolDefinition objects and converted to
# OpenAI format with security_risk parameter during LLM completion.
# See make_llm_completion() in agent/utils.py for details.
# Agent calls always expose security risk prediction in tool schemas.
tools=list(self.tools_map.values()),
dynamic_context=TextContent(text=dynamic_context)
if dynamic_context
Expand Down Expand Up @@ -727,10 +725,11 @@ def _step(
)

try:
llm_response = make_llm_completion(
self.llm,
_messages,
llm_response = self.llm.generate(
messages=_messages,
tools=list(self.tools_map.values()),
store=False,
add_security_risk_prediction=True,
on_token=stream.token_callback,
call_context=call_context,
)
Expand Down Expand Up @@ -844,7 +843,7 @@ async def astep(
"""Async variant of :meth:`step`.

The LLM completion is performed asynchronously via
:func:`amake_llm_completion`. Tool dispatch uses
:meth:`LLM.agenerate`. Tool dispatch uses
:meth:`_aexecute_actions` which runs each tool call in its own
thread via :func:`asyncio.loop.run_in_executor` and schedules
parallel calls with :func:`asyncio.gather`, keeping the event
Expand Down Expand Up @@ -934,10 +933,11 @@ async def _astep(
# and state snapshots aren't blocked for the whole response. No-op
# unless the run loop holds the lock (e.g. direct astep() in tests).
async with conversation._released_state_lock_during_io():
llm_response = await amake_llm_completion(
self.llm,
_messages,
llm_response = await self.llm.agenerate(
messages=_messages,
tools=list(self.tools_map.values()),
store=False,
add_security_risk_prediction=True,
on_token=stream.token_callback,
call_context=call_context,
)
Expand Down
8 changes: 5 additions & 3 deletions openhands-sdk/openhands/sdk/agent/stream_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,11 @@ def _split_chunk(
delta = choice.delta
if delta is None:
continue
# getattr, not attribute access: litellm *deletes* reasoning_content
# when the provider omits it, declared field or not.
reasoning = getattr(delta, "reasoning_content", None)
reasoning = (
delta.reasoning_content
if "reasoning_content" in delta.model_fields_set
else None
)
if isinstance(reasoning, str) and reasoning:
out.append(("reasoning", reasoning, chunk.id, choice.index))
if isinstance(delta.content, str) and delta.content:
Expand Down
89 changes: 1 addition & 88 deletions openhands-sdk/openhands/sdk/agent/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import types
from collections.abc import Collection
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Union,
Expand All @@ -25,16 +24,9 @@

from openhands.sdk.context.condenser.base import CondenserBase
from openhands.sdk.context.view import View
from openhands.sdk.conversation.types import ConversationTokenCallbackType
from openhands.sdk.event.base import LLMConvertibleEvent
from openhands.sdk.event.condenser import Condensation
from openhands.sdk.llm import LLM, LLMResponse, Message
from openhands.sdk.tool import ToolDefinition


if TYPE_CHECKING:
from openhands.sdk.llm.llm import LLMCallContext
from openhands.sdk.llm.streaming import AnyTokenCallbackType
from openhands.sdk.llm import LLM, Message


# Regex matching raw ASCII control characters (U+0000–U+001F) that are
Expand Down Expand Up @@ -641,57 +633,6 @@ def prepare_llm_messages(
return messages


def make_llm_completion(
llm: LLM,
messages: list[Message],
tools: list[ToolDefinition] | None = None,
on_token: ConversationTokenCallbackType | None = None,
call_context: LLMCallContext | None = None,
) -> LLMResponse:
"""Make an LLM completion call with the provided messages and tools.

Args:
llm: The LLM instance to use for completion
messages: The messages to send to the LLM
tools: Optional list of tools to provide to the LLM
on_token: Optional callback for streaming token updates
call_context: Per-conversation context for cache/session affinity.

Returns:
LLMResponse from the LLM completion call

Note:
Always exposes a 'security_risk' parameter in tool schemas via
add_security_risk_prediction=True. This ensures the schema remains
consistent, even if the security analyzer is disabled. Validation of
this field happens dynamically at runtime depending on the analyzer
configured. This allows weaker models to omit risk field and bypass
validation requirements when analyzer is disabled. For detailed logic,
see `_extract_security_risk` method in agent.py.

Summary field is always added to tool schemas for transparency and
explainability of agent actions.
"""
if llm.uses_responses_api():
return llm.responses(
messages=messages,
tools=tools or [],
include=None,
store=False,
add_security_risk_prediction=True,
on_token=on_token,
call_context=call_context,
)
else:
return llm.completion(
messages=messages,
tools=tools or [],
add_security_risk_prediction=True,
on_token=on_token,
call_context=call_context,
)


# ---------------------------------------------------------------------------
# Async variants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -725,31 +666,3 @@ async def aprepare_llm_messages(
messages.extend(additional_messages)

return messages


async def amake_llm_completion(
llm: LLM,
messages: list[Message],
tools: list[ToolDefinition] | None = None,
on_token: AnyTokenCallbackType | None = None,
call_context: LLMCallContext | None = None,
) -> LLMResponse:
"""Async variant of :func:`make_llm_completion`."""
if llm.uses_responses_api():
return await llm.aresponses(
messages=messages,
tools=tools or [],
include=None,
store=False,
add_security_risk_prediction=True,
on_token=on_token,
call_context=call_context,
)
else:
return await llm.acompletion(
messages=messages,
tools=tools or [],
add_security_risk_prediction=True,
on_token=on_token,
call_context=call_context,
)
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,8 @@ def _generate_condensation(

# Do not pass extra_body explicitly. The LLM handles forwarding
# litellm_extra_body only when it is non-empty.
from openhands.sdk.agent.utils import make_llm_completion

try:
llm_response = make_llm_completion(
llm=self.llm,
messages=messages,
)
llm_response = self.llm.generate(messages=messages, store=False)
except Exception as e:
raise NoCondensationAvailableException(
f"Summarization LLM call failed: {e}"
Expand Down Expand Up @@ -423,13 +418,9 @@ async def _agenerate_condensation(
)

messages = [Message(role="user", content=[TextContent(text=prompt)])]
from openhands.sdk.agent.utils import amake_llm_completion

try:
llm_response = await amake_llm_completion(
llm=self.llm,
messages=messages,
)
llm_response = await self.llm.agenerate(messages=messages, store=False)
except Exception as e:
raise NoCondensationAvailableException(
f"Summarization LLM call failed: {e}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1626,8 +1626,7 @@ def _bind_conversation_context(self, llm: LLM) -> None:
thread an explicit ``call_context`` through the completion call
(e.g. the condenser's dedicated LLM) still get correct per-
conversation state. The primary agent completion path threads
context explicitly via ``Agent.step()`` → ``make_llm_completion()``
→ ``llm.completion(call_context=...)``.
context explicitly via ``Agent.step()`` → ``llm.generate(call_context=...)``.

See #3443 for background.
"""
Expand Down Expand Up @@ -2859,7 +2858,7 @@ def ask_agent(self, question: str) -> str:
return agent_response

# Import here to avoid circular imports
from openhands.sdk.agent.utils import make_llm_completion, prepare_llm_messages
from openhands.sdk.agent.utils import prepare_llm_messages

template_dir = (
Path(__file__).parent.parent.parent / "context" / "prompts" / "templates"
Expand Down Expand Up @@ -2895,8 +2894,10 @@ def ask_agent(self, question: str) -> str:
self.llm_registry.add(question_llm)

# Pass agent tools so LLM can understand tool_calls in conversation history
response = make_llm_completion(
question_llm, messages, tools=list(self.agent.tools_map.values())
response = question_llm.generate(
messages=messages,
tools=list(self.agent.tools_map.values()),
store=False,
)

message = response.message
Expand Down
7 changes: 1 addition & 6 deletions openhands-sdk/openhands/sdk/conversation/title_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,7 @@ def generate_title_with_llm(
),
]

# Force non-streaming: the title is consumed whole with no on_token
# callback, which a streaming LLM requires.
if llm.stream:
llm = llm.model_copy(update={"stream": False})

response = llm.completion(messages)
response = llm.generate(messages, store=False)

# Extract the title from the response
if response.message.content and isinstance(
Expand Down
3 changes: 1 addition & 2 deletions openhands-sdk/openhands/sdk/hooks/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

from pydantic import BaseModel

from openhands.sdk.agent.utils import make_llm_completion
from openhands.sdk.conversation.visualizer import ConversationVisualizerBase
from openhands.sdk.hooks.config import HookDefinition, HookType
from openhands.sdk.hooks.types import HookDecision, HookEvent
Expand Down Expand Up @@ -359,7 +358,7 @@ def _execute_prompt_hook(
]

try:
response = make_llm_completion(hook_llm, messages)
response = hook_llm.generate(messages=messages, store=False)
raw = "\n".join(content_to_str(response.message.content))
except Exception as e:
logger.warning(
Expand Down
14 changes: 4 additions & 10 deletions openhands-sdk/openhands/sdk/llm/cleanup_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,8 @@ def clean_outward_text(text: str, *, cipher: Cipher | None = None) -> str:
if cleanup_llm is None:
return text

# Imported lazily: ``agent.utils`` imports from ``openhands.sdk.llm``, so a
# module-level import here would create a circular import at package init.
from openhands.sdk.agent.utils import make_llm_completion

try:
response = make_llm_completion(cleanup_llm, _cleanup_messages(text))
response = cleanup_llm.generate(messages=_cleanup_messages(text), store=False)
except Exception as exc:
logger.warning("Cleanup profile call failed; sending original text: %s", exc)
return text
Expand All @@ -153,12 +149,10 @@ async def aclean_outward_text(text: str, *, cipher: Cipher | None = None) -> str
if cleanup_llm is None:
return text

# Imported lazily: ``agent.utils`` imports from ``openhands.sdk.llm``, so a
# module-level import here would create a circular import at package init.
from openhands.sdk.agent.utils import amake_llm_completion

try:
response = await amake_llm_completion(cleanup_llm, _cleanup_messages(text))
response = await cleanup_llm.agenerate(
messages=_cleanup_messages(text), store=False
)
except Exception as exc:
logger.warning("Cleanup profile call failed; sending original text: %s", exc)
return text
Expand Down
Loading
Loading