Skip to content

Commit 79ad28f

Browse files
authored
Merge branch 'main' into img
2 parents cbfa590 + f3c0c19 commit 79ad28f

File tree

15 files changed

+617
-97
lines changed

15 files changed

+617
-97
lines changed

openhands-agent-server/openhands/agent_server/file_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
@file_router.post("/upload/{path:path}")
3131
async def upload_file(
3232
path: Annotated[str, FastApiPath(alias="path", description="Absolute file path.")],
33-
file: UploadFile = File(...),
33+
file: Annotated[UploadFile, File(...)],
3434
) -> Success:
3535
"""Upload a file to the workspace."""
3636
logger.info(f"Uploading file: {path}")

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,9 @@ def _get_action_event(
309309
tool_call: MessageToolCall,
310310
llm_response_id: str,
311311
on_event: ConversationCallbackType,
312-
thought: list[TextContent] = [],
312+
thought: list[TextContent] | None = None,
313313
reasoning_content: str | None = None,
314-
thinking_blocks: list[ThinkingBlock | RedactedThinkingBlock] = [],
314+
thinking_blocks: list[ThinkingBlock | RedactedThinkingBlock] | None = None,
315315
responses_reasoning_item: ReasoningItemModel | None = None,
316316
) -> ActionEvent | None:
317317
"""Converts a tool call into an ActionEvent, validating arguments.
@@ -328,9 +328,9 @@ def _get_action_event(
328328
# Persist assistant function_call so next turn has matching call_id
329329
tc_event = ActionEvent(
330330
source="agent",
331-
thought=thought,
331+
thought=thought or [],
332332
reasoning_content=reasoning_content,
333-
thinking_blocks=thinking_blocks,
333+
thinking_blocks=thinking_blocks or [],
334334
responses_reasoning_item=responses_reasoning_item,
335335
tool_call=tool_call,
336336
tool_name=tool_call.name,
@@ -380,9 +380,9 @@ def _get_action_event(
380380
# Persist assistant function_call so next turn has matching call_id
381381
tc_event = ActionEvent(
382382
source="agent",
383-
thought=thought,
383+
thought=thought or [],
384384
reasoning_content=reasoning_content,
385-
thinking_blocks=thinking_blocks,
385+
thinking_blocks=thinking_blocks or [],
386386
responses_reasoning_item=responses_reasoning_item,
387387
tool_call=tool_call,
388388
tool_name=tool_call.name,
@@ -401,9 +401,9 @@ def _get_action_event(
401401

402402
action_event = ActionEvent(
403403
action=action,
404-
thought=thought,
404+
thought=thought or [],
405405
reasoning_content=reasoning_content,
406-
thinking_blocks=thinking_blocks,
406+
thinking_blocks=thinking_blocks or [],
407407
responses_reasoning_item=responses_reasoning_item,
408408
tool_name=tool.name,
409409
tool_call_id=tool_call.id,

openhands-sdk/openhands/sdk/conversation/visualizer/default.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
UserRejectObservation,
1818
)
1919
from openhands.sdk.event.base import Event
20-
from openhands.sdk.event.condenser import Condensation
20+
from openhands.sdk.event.condenser import Condensation, CondensationRequest
2121

2222

2323
# These are external inputs
@@ -252,6 +252,19 @@ def _create_event_panel(self, event: Event) -> Panel | None:
252252
border_style=_SYSTEM_COLOR,
253253
expand=True,
254254
)
255+
256+
elif isinstance(event, CondensationRequest):
257+
title = f"[bold {_SYSTEM_COLOR}]"
258+
if self._name:
259+
title += f"{self._name} "
260+
title += f"Condensation Request[/bold {_SYSTEM_COLOR}]"
261+
return Panel(
262+
content,
263+
title=title,
264+
border_style=_SYSTEM_COLOR,
265+
padding=_PANEL_PADDING,
266+
expand=True,
267+
)
255268
else:
256269
# Fallback panel for unknown event types
257270
title = f"[bold {_ERROR_COLOR}]"

openhands-sdk/openhands/sdk/event/condenser.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ class CondensationRequest(Event):
5555

5656
source: SourceType = "environment"
5757

58+
@property
59+
def visualize(self) -> Text:
60+
text = Text()
61+
text.append("Conversation Condensation Requested\n", style="bold")
62+
message = (
63+
"A condensation of the conversation history has been requested to "
64+
"manage context window usage.\n"
65+
)
66+
text.append(message)
67+
return text
68+
5869

5970
class CondensationSummaryEvent(LLMConvertibleEvent):
6071
"""This event represents a summary generated by a condenser."""

openhands-sdk/openhands/sdk/llm/llm.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
280280
"api_key",
281281
"aws_access_key_id",
282282
"aws_secret_access_key",
283+
# Dynamic runtime metadata for telemetry/routing that can differ across sessions
284+
# and should not cause resume-time diffs. Always prefer the runtime value.
285+
"litellm_extra_body",
283286
)
284287

285288
# Runtime-only private attrs
@@ -528,7 +531,9 @@ def _one_attempt(**retry_kwargs) -> ModelResponse:
528531
# 6) telemetry
529532
self._telemetry.on_response(resp, raw_resp=raw_resp)
530533

531-
# Ensure at least one choice
534+
# Ensure at least one choice.
535+
# Gemini sometimes returns empty choices; we raise LLMNoResponseError here
536+
# inside the retry boundary so it is retried.
532537
if not resp.get("choices") or len(resp["choices"]) < 1:
533538
raise LLMNoResponseError(
534539
"Response choices is less than 1. Response: " + str(resp)

openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py

Lines changed: 162 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import re
1212
import sys
1313
from collections.abc import Iterable
14-
from typing import Literal, NotRequired, TypedDict, cast
14+
from typing import Any, Literal, NotRequired, TypedDict, cast
1515

1616
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
1717

@@ -41,6 +41,11 @@ class TextPart(TypedDict):
4141
TASK_TRACKER_TOOL_NAME = "task_tracker"
4242

4343
# Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b
44+
MISSING_DESCRIPTION_PLACEHOLDER = "No description provided"
45+
SCHEMA_INDENT_STEP = 2
46+
SCHEMA_UNION_KEYS = ("anyOf", "oneOf", "allOf")
47+
48+
4449
system_message_suffix_TEMPLATE = """
4550
You have access to the following functions:
4651
@@ -487,6 +492,155 @@ def convert_tool_call_to_string(tool_call: dict) -> str:
487492
return ret
488493

489494

495+
def _summarize_schema_type(schema: object | None) -> str:
496+
"""
497+
Capture array, union, enum, and nested type info.
498+
"""
499+
if not isinstance(schema, dict):
500+
return "unknown" if schema is None else str(schema)
501+
502+
for key in SCHEMA_UNION_KEYS:
503+
if key in schema:
504+
return " or ".join(_summarize_schema_type(option) for option in schema[key])
505+
506+
schema_type = schema.get("type")
507+
if isinstance(schema_type, list):
508+
return " or ".join(str(t) for t in schema_type)
509+
if schema_type == "array":
510+
items = schema.get("items")
511+
if isinstance(items, list):
512+
item_types = ", ".join(_summarize_schema_type(item) for item in items)
513+
return f"array[{item_types}]"
514+
if isinstance(items, dict):
515+
return f"array[{_summarize_schema_type(items)}]"
516+
return "array"
517+
if schema_type:
518+
return str(schema_type)
519+
if "enum" in schema:
520+
return "enum"
521+
return "unknown"
522+
523+
524+
def _indent(indent: int) -> str:
525+
return " " * indent
526+
527+
528+
def _nested_indent(indent: int, levels: int = 1) -> int:
529+
return indent + SCHEMA_INDENT_STEP * levels
530+
531+
532+
def _get_description(schema: dict[str, object] | None) -> str:
533+
"""
534+
Extract description from schema, or return placeholder if missing.
535+
"""
536+
if not isinstance(schema, dict):
537+
return MISSING_DESCRIPTION_PLACEHOLDER
538+
description = schema.get("description")
539+
if isinstance(description, str) and description.strip():
540+
return description
541+
return MISSING_DESCRIPTION_PLACEHOLDER
542+
543+
544+
def _format_union_details(schema: dict[str, object], indent: int) -> list[str] | None:
545+
for key in SCHEMA_UNION_KEYS:
546+
options = schema.get(key)
547+
if not isinstance(options, list):
548+
continue
549+
lines = [f"{_indent(indent)}{key} options:"]
550+
for option in options:
551+
option_type = _summarize_schema_type(option)
552+
option_line = f"{_indent(_nested_indent(indent))}- {option_type}"
553+
option_line += (
554+
f": {_get_description(option if isinstance(option, dict) else None)}"
555+
)
556+
lines.append(option_line)
557+
lines.extend(_format_schema_detail(option, _nested_indent(indent, 2)))
558+
return lines
559+
return None
560+
561+
562+
def _format_array_details(schema: dict[str, object], indent: int) -> list[str]:
563+
lines = [f"{_indent(indent)}Array items:"]
564+
items = schema.get("items")
565+
if isinstance(items, list):
566+
for index, item_schema in enumerate(items):
567+
item_type = _summarize_schema_type(item_schema)
568+
lines.append(
569+
f"{_indent(_nested_indent(indent))}- index {index}: {item_type}"
570+
)
571+
lines.extend(_format_schema_detail(item_schema, _nested_indent(indent, 2)))
572+
elif isinstance(items, dict):
573+
lines.append(
574+
f"{_indent(_nested_indent(indent))}Type: {_summarize_schema_type(items)}"
575+
)
576+
lines.extend(_format_schema_detail(items, _nested_indent(indent, 2)))
577+
else:
578+
lines.append(f"{_indent(_nested_indent(indent))}Type: unknown")
579+
return lines
580+
581+
582+
def _format_additional_properties(
583+
additional_props: object | None, indent: int
584+
) -> list[str]:
585+
if isinstance(additional_props, dict):
586+
line = (
587+
f"{_indent(indent)}Additional properties allowed: "
588+
f"{_summarize_schema_type(additional_props)}"
589+
)
590+
lines = [line]
591+
lines.extend(_format_schema_detail(additional_props, _nested_indent(indent)))
592+
return lines
593+
if additional_props is True:
594+
return [f"{_indent(indent)}Additional properties allowed."]
595+
if additional_props is False:
596+
return [f"{_indent(indent)}Additional properties not allowed."]
597+
return []
598+
599+
600+
def _format_object_details(schema: dict[str, Any], indent: int) -> list[str]:
601+
lines: list[str] = []
602+
properties = schema.get("properties", {})
603+
required = set(schema.get("required", []))
604+
if isinstance(properties, dict) and properties:
605+
lines.append(f"{_indent(indent)}Object properties:")
606+
for name, prop in properties.items():
607+
prop_type = _summarize_schema_type(prop)
608+
required_flag = "required" if name in required else "optional"
609+
prop_desc = _get_description(prop if isinstance(prop, dict) else None)
610+
lines.append(
611+
f"{_indent(_nested_indent(indent))}- {name} ({prop_type},"
612+
f" {required_flag}): {prop_desc}"
613+
)
614+
lines.extend(_format_schema_detail(prop, _nested_indent(indent, 2)))
615+
lines.extend(
616+
_format_additional_properties(schema.get("additionalProperties"), indent)
617+
)
618+
return lines
619+
620+
621+
def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]:
622+
"""Recursively describe arrays, objects, unions, and additional properties."""
623+
if not isinstance(schema, dict):
624+
return []
625+
626+
union_lines = _format_union_details(schema, indent)
627+
if union_lines is not None:
628+
return union_lines
629+
630+
schema_type = schema.get("type")
631+
if isinstance(schema_type, list):
632+
allowed_types = ", ".join(str(t) for t in schema_type)
633+
return [f"{_indent(indent)}Allowed types: {allowed_types}"]
634+
635+
if schema_type == "array":
636+
return _format_array_details(schema, indent)
637+
638+
if schema_type == "object":
639+
return _format_object_details(schema, indent)
640+
641+
return []
642+
643+
490644
def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
491645
ret = ""
492646
for i, tool in enumerate(tools):
@@ -504,15 +658,14 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
504658
required_params = set(fn["parameters"].get("required", []))
505659

506660
for j, (param_name, param_info) in enumerate(properties.items()):
507-
# Indicate required/optional in parentheses with type
508661
is_required = param_name in required_params
509662
param_status = "required" if is_required else "optional"
510-
param_type = param_info.get("type", "string")
663+
param_type = _summarize_schema_type(param_info)
511664

512-
# Get parameter description
513-
desc = param_info.get("description", "No description provided")
665+
desc = _get_description(
666+
param_info if isinstance(param_info, dict) else None
667+
)
514668

515-
# Handle enum values if present
516669
if "enum" in param_info:
517670
enum_values = ", ".join(f"`{v}`" for v in param_info["enum"])
518671
desc += f"\nAllowed values: [{enum_values}]"
@@ -521,34 +674,10 @@ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
521674
f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n"
522675
)
523676

524-
# Handle nested structure for array/object types
525-
if param_type == "array" and "items" in param_info:
526-
items = param_info["items"]
527-
if items.get("type") == "object" and "properties" in items:
528-
ret += " task_list array item structure:\n"
529-
item_properties = items["properties"]
530-
item_required = set(items.get("required", []))
531-
for k, (item_param_name, item_param_info) in enumerate(
532-
item_properties.items()
533-
):
534-
item_is_required = item_param_name in item_required
535-
item_status = "required" if item_is_required else "optional"
536-
item_type = item_param_info.get("type", "string")
537-
item_desc = item_param_info.get(
538-
"description", "No description provided"
539-
)
540-
541-
# Handle enum values for nested items
542-
if "enum" in item_param_info:
543-
item_enum_values = ", ".join(
544-
f"`{v}`" for v in item_param_info["enum"]
545-
)
546-
item_desc += f" Allowed values: [{item_enum_values}]"
677+
detail_lines = _format_schema_detail(param_info, indent=6)
678+
if detail_lines:
679+
ret += "\n".join(detail_lines) + "\n"
547680

548-
ret += (
549-
f" - {item_param_name} ({item_type}, "
550-
f"{item_status}): {item_desc}\n"
551-
)
552681
else:
553682
ret += "No parameters are required for this function.\n"
554683

openhands-sdk/openhands/sdk/llm/utils/telemetry.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,8 @@ def log_llm_call(
217217
if not self.log_dir:
218218
return
219219
try:
220-
# Only log if directory exists and is writable.
221-
# Do not create directories implicitly.
222-
if not os.path.isdir(self.log_dir):
223-
raise FileNotFoundError(f"log_dir does not exist: {self.log_dir}")
220+
# Create log directory if it doesn't exist
221+
os.makedirs(self.log_dir, exist_ok=True)
224222
if not os.access(self.log_dir, os.W_OK):
225223
raise PermissionError(f"log_dir is not writable: {self.log_dir}")
226224

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,25 @@ select = [
4343
"UP", # pyupgrade
4444
"ARG", # flake8-unused-arguments
4545
]
46+
# Enforce rules that catch mutable defaults and related pitfalls
47+
# - B006: mutable-argument-default
48+
# - B008: function-call-in-default-argument
49+
# - B039: mutable-contextvar-default
50+
# - RUF012: mutable-class-default
51+
extend-select = ["B006", "B008", "B039", "RUF012"]
4652

4753
[tool.ruff.lint.per-file-ignores]
4854
# Test files often have unused arguments (fixtures, mocks, interface implementations)
4955
"tests/**/*.py" = ["ARG"]
5056

57+
58+
# Allowlist safe default calls for flake8-bugbear rules (e.g., FastAPI Depends)
59+
[tool.ruff.lint.flake8-bugbear]
60+
extend-immutable-calls = [
61+
"fastapi.Depends",
62+
"fastapi.params.Depends",
63+
]
64+
5165
[tool.ruff.lint.isort]
5266
known-first-party = ["openhands"]
5367
combine-as-imports = true

0 commit comments

Comments
 (0)