Skip to content
Open
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
58 changes: 43 additions & 15 deletions openhands/agenthub/codeact_agent/function_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,18 @@ def response_to_actions(
raise FunctionCallValidationError(
f'Missing required argument "command" in tool call {tool_call.function.name}'
)
if arguments['command'] == 'plan' and 'task_list' not in arguments:

command = arguments['command']
if command not in {'view', 'plan'}:
raise FunctionCallValidationError(
'Invalid "command" value for task_tracker. Allowed values: {"view", "plan"}. '
'Refer to the task_tracker parameters for the expected schema.'
)

if command == 'plan' and 'task_list' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}'
f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}. '
'Expected schema: task_list -> array of {id, title, status, notes}.'
)

raw_task_list = arguments.get('task_list', [])
Expand All @@ -269,29 +278,48 @@ def response_to_actions(
f'Invalid format for "task_list". Expected a list but got {type(raw_task_list)}.'
)

# Normalize task_list to ensure it's always a list of dictionaries
allowed_task_fields = {'id', 'title', 'status', 'notes'}
normalized_task_list = []
for i, task in enumerate(raw_task_list):
if isinstance(task, dict):
# Task is already in correct format, ensure required fields exist
normalized_task = {
'id': task.get('id', f'task-{i + 1}'),
'title': task.get('title', 'Untitled task'),
'status': task.get('status', 'todo'),
'notes': task.get('notes', ''),
}
else:
# Unexpected format, raise validation error
for task in raw_task_list:
if not isinstance(task, dict):
logger.warning(
f'Unexpected task format in task_list: {type(task)} - {task}'
)
raise FunctionCallValidationError(
f'Unexpected task format in task_list: {type(task)}. Each task should be a dictionary.'
)

unexpected_keys = set(task.keys()) - allowed_task_fields
if unexpected_keys:
raise FunctionCallValidationError(
'Unexpected keys in task_list entry: '
f"{', '.join(sorted(unexpected_keys))}. Allowed keys: {allowed_task_fields}."
)

missing_keys = {'id', 'title', 'status'} - set(task.keys())
if missing_keys:
raise FunctionCallValidationError(
'Missing required fields in task_list entry: '
f"{', '.join(sorted(missing_keys))}. Each task must include id, title, and status."
)

status = task['status']
if status not in {'todo', 'in_progress', 'done', 'blocked'}:
raise FunctionCallValidationError(
'Invalid task status in task_list entry. '
'Allowed values: {"todo", "in_progress", "done", "blocked"}.'
)

normalized_task = {
'id': task['id'],
'title': task['title'],
'status': status,
'notes': task.get('notes', ''),
}
normalized_task_list.append(normalized_task)

action = TaskTrackingAction(
command=arguments['command'],
command=command,
task_list=normalized_task_list,
)

Expand Down
56 changes: 42 additions & 14 deletions openhands/runtime/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -983,7 +983,9 @@ def run_action(self, action: Action) -> Observation:

if action.command == 'plan':
# Validate that the task list is correctly populated.
self._validate_task_list(action.task_list)
validation_error = self._validate_task_list(action.task_list)
if validation_error:
return validation_error

# Write the serialized task list to the session directory
content = '# Task List\n\n'
Expand Down Expand Up @@ -1263,37 +1265,63 @@ def get_workspace_branch(self, primary_repo_path: str | None = None) -> str | No
# ====================================================================

@classmethod
def _validate_task_list(cls, task_list: list[dict[str, str]]) -> None:
def render_keys_error(error_prefix: str,
task_identifier: str,
keys: set[str]) -> ErrorObservation:
keys_str = ",".join(keys)
def _validate_task_list(
cls, task_list: list[dict[str, str]]
) -> ErrorObservation | None:
def render_keys_error(
error_prefix: str, task_identifier: str, keys: set[str]
) -> ErrorObservation:
keys_str = ",".join(sorted(keys))
return ErrorObservation(
f"Task list was not updated: {error_prefix} in task {task_identifier}: [{keys_str}]"
)

allowed_statuses = {"todo", "in_progress", "done", "blocked"}

for i, task in enumerate(task_list, 1):
identifier = f"ID '{task_id}'" if (task_id := task.get("id")) else f"Task #{i}"
if not isinstance(task, dict):
return ErrorObservation(
f"Task list was not updated: task #{i} must be an object with keys id, title, status, and optional notes."
)

identifier = (
f"ID '{task_id}'" if (task_id := task.get("id")) else f"Task #{i}"
)

required_keys = {"id", "title", "status"}
optional_keys = {"notes"}

task_keys = task.keys()
task_keys = set(task.keys())
unexpected_keys = task_keys - required_keys - optional_keys
missing_keys = task_keys - required_keys
missing_keys = required_keys - task_keys
empty_required_keys = {
k for k, v in task.items()
if k in required_keys and len(v.strip()) == 0
k
for k in required_keys
if not str(task.get(k, "")).strip()
}

if unexpected_keys:
render_keys_error("unexpected keys", identifier, unexpected_keys)
return render_keys_error("unexpected keys", identifier, unexpected_keys)

if missing_keys:
render_keys_error("required keys are missing", identifier, missing_keys)
return render_keys_error(
"required keys are missing", identifier, missing_keys
)

if empty_required_keys:
render_keys_error("required keys are empty but must not be", identifier, empty_required_keys)
return render_keys_error(
"required keys are empty but must not be",
identifier,
empty_required_keys,
)

if task["status"] not in allowed_statuses:
return ErrorObservation(
f"Task list was not updated: invalid status in task {identifier}: {task['status']}. "
f"Allowed values: {sorted(allowed_statuses)}"
)

return None

# ====================================================================
# Lifecycle Events
Expand Down
Loading