{"record":{"id":"9cce72d6fd7d0669","repo":"tinyhumansai/openhuman","slug":"missing-plan-parameter","errorCode":null,"errorMessage":"Missing 'plan' parameter","messagePattern":"Missing 'plan' parameter","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"src/openhuman/agent/tools/plan_exit.rs","lineNumber":69,"sourceCode":"            \"properties\": {\n                \"plan\": {\n                    \"type\": \"string\",\n                    \"description\": \"Markdown-formatted plan text to hand off.\"\n                }\n            },\n            \"required\": [\"plan\"]\n        })\n    }\n\n    fn permission_level(&self) -> PermissionLevel {\n        PermissionLevel::None\n    }\n\n    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {\n        let plan = args\n            .get(\"plan\")\n            .and_then(|v| v.as_str())\n            .ok_or_else(|| anyhow::anyhow!(\"Missing 'plan' parameter\"))?;\n        let trimmed = plan.trim();\n        if trimmed.is_empty() {\n            return Ok(ToolResult::error(\"`plan` must not be empty\"));\n        }\n        Ok(ToolResult::success(format!(\n            \"{PLAN_EXIT_MARKER}\\n{trimmed}\"\n        )))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[tokio::test]\n    async fn plan_exit_emits_marker() {\n        let tool = PlanExitTool::new();\n        let result = tool","sourceCodeStart":51,"sourceCodeEnd":87,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/a221052e0df5b1f7598fceba7329fd1af95d6699/src/openhuman/agent/tools/plan_exit.rs#L51-L87","documentation":"The `plan_exit` tool was invoked without a `plan` string (plan_exit.rs:69). The tool's only job is to emit PLAN_EXIT_MARKER followed by the trimmed plan text, so the argument is the entire payload. The empty-after-trim case is a separate soft ToolResult::error; this error means args[\"plan\"] is absent or not a string.","triggerScenarios":"Model calls plan_exit with no arguments or an empty object; plan passed as an array of steps instead of a string; wrapper dropping the field.","commonSituations":"Plan-review flows where the model assumes the plan is carried in context; argument-shape drift after prompt changes.","solutions":["Pass \"plan\" as the full plan text string","If the plan is naturally a list, join it into one string before calling","Validate args against the schema (required: [\"plan\"]) in any wrapper"],"exampleFix":"// before\n{}\n\n// after\n{ \"plan\": \"1. Reproduce\\n2. Fix null check in loader\\n3. Add regression test\" }","handlingStrategy":"validation","validationCode":"const a = args as Record<string, unknown>;\nif (typeof a?.plan !== \"string\") {\n  throw new Error(\"plan_exit: 'plan' string is required\");\n}","typeGuard":"function isPlanExitArgs(a: unknown): a is { plan: string } {\n  return typeof (a as Record<string, unknown>)?.plan === \"string\"\n      && ((a as { plan: string }).plan.trim().length > 0);\n}","tryCatchPattern":"// plan_exit is terminal for the plan loop — feed a correctable error back\nif e.to_string().contains(\"Missing 'plan'\") {\n    return Ok(ToolResult::error(\"call plan_exit with the final plan as the 'plan' string\"));\n}","preventionTips":["Instruct the model that plan_exit carries the whole plan in one string","Join multi-step plans into a single string before calling","Keep required: [\"plan\"] aligned with the execute() read"],"tags":["rust","tool","arguments","llm","plan-review"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}