diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index 763b75ee534d..2a1e9b2fe818 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -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', []) @@ -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, ) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 91dbc0ce7730..c8dfef402f43 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -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' @@ -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