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
9 changes: 9 additions & 0 deletions agents/embodied-service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ async function handleIntent(req, res) {
return jsonResponse(res, 200, {
ok: false,
context_id,
// Populate `error` so upstream consumers (embodied_plan_tool.py,
// local_agent/embodied.py) don't fall through to "unknown error".
// The structured `plan`/`execution_results`/`mitigations` below
// are kept for full observability.
error: {
error_type: "empty_model_response",
details:
"Ollama returned empty raw output; consumer-side mitigation synthesized raise_guardian_event(model_unavailable)",
},
plan: {
body_plan: ["model returned empty response; consumer-side mitigation"],
checks: ["parse_failed with empty raw — likely Ollama or model availability issue"],
Expand Down
41 changes: 37 additions & 4 deletions agents/embodied-service/lib/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,32 @@ async function botAction(name, body, botUrl = null) {
return foldBotResponse(r);
}

/**
* Canonical spatial-failure classifiers — pattern-match the bot's error
* string and promote a generic `bot_action_failed` / `bot_soft_failure`
* to one of the three canonical spatial error_types the runner's Tier 2a
* auto-recovery is keyed on (`local_agent/embodied.py:SPATIAL_ERRORS`
* and `hermes-agent/tools/embodied_plan_tool.py` retry block).
*
* Kept deliberately small (3 patterns) to avoid drift from the runner's
* expected set. Anything not matched here falls through to the generic
* `bot_action_failed` / `bot_soft_failure` and is recovered at the SOUL
* pattern-matching layer instead.
*/
const SPATIAL_ERROR_CLASSIFIERS = [
[/target space is occupied/i, "target_occupied"],
[/inside my own body|own footprint/i, "bot_in_target"],
[/no solid adjacent block|no solid neighbour to place against/i, "no_solid_neighbor"],
];

function classifyBotError(details) {
if (!details || typeof details !== "string") return null;
for (const [pattern, error_type] of SPATIAL_ERROR_CLASSIFIERS) {
if (pattern.test(details)) return error_type;
}
return null;
}

/**
* Fold the bot's `{ok, status, body}` into `{ok, data?, error?, error_type?, status?}`.
*
Expand All @@ -379,6 +405,12 @@ async function botAction(name, body, botUrl = null) {
* which misleads upstream agents trying to recover. Detected via
* `result` string patterns and surfaced as `ok=false` with
* `error_type: "bot_soft_failure"`.
*
* Spatial placement failures are further classified into canonical
* error_types (`target_occupied | bot_in_target | no_solid_neighbor`)
* via `classifyBotError()` so the runner's Tier 2a auto-recovery (which
* is keyed on these exact strings) fires deterministically instead of
* relying on the SOUL pattern-matching fallback.
*/
function foldBotResponse(r) {
if (r.ok) {
Expand All @@ -387,17 +419,18 @@ function foldBotResponse(r) {
if (softFailure) {
return {
ok: false,
error_type: "bot_soft_failure",
error_type: classifyBotError(softFailure) || "bot_soft_failure",
details: softFailure,
data: rest,
};
}
return { ok: true, data: rest };
}
const details = r.body?.error ?? `bot returned status ${r.status}`;
return {
ok: false,
error_type: "bot_action_failed",
details: r.body?.error ?? `bot returned status ${r.status}`,
error_type: classifyBotError(details) || "bot_action_failed",
details,
status: r.status,
};
}
Expand Down Expand Up @@ -512,4 +545,4 @@ export async function dispatch(toolCall, botUrl = null, allowedTools = null) {
}
}

export { SIGNAL_TOOLS, HANDLERS, DEFAULT_BOT_URL, foldBotResponse, detectSoftFailure };
export { SIGNAL_TOOLS, HANDLERS, DEFAULT_BOT_URL, foldBotResponse, detectSoftFailure, classifyBotError };
77 changes: 76 additions & 1 deletion agents/embodied-service/test/dispatcher.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { dispatch, SIGNAL_TOOLS, HANDLERS, foldBotResponse, detectSoftFailure } from "../lib/dispatcher.js";
import { dispatch, SIGNAL_TOOLS, HANDLERS, foldBotResponse, detectSoftFailure, classifyBotError } from "../lib/dispatcher.js";
import { _reset } from "../lib/schema.js";

describe("dispatcher", () => {
Expand Down Expand Up @@ -175,3 +175,78 @@ describe("executor semantic tests (Layer 4: ok reflects embodied success, not to
assert.match(out, /Refusing to/);
});
});

// ── Spatial error classifier ───────────────────────────────────────────────
// Promotes a bot's free-text error to one of three canonical error_types
// the runner's Tier 2a auto-recovery is keyed on
// (local_agent/embodied.py:SPATIAL_ERRORS and
// hermes-agent/tools/embodied_plan_tool.py retry block).
describe("classifyBotError", () => {
it("classifies 'target space is occupied by ...' as target_occupied", () => {
const msg = "Can't place crafting_table at 0, 70, 1: target space is occupied by prismarine. Dig that block first or choose an empty adjacent space.";
assert.equal(classifyBotError(msg), "target_occupied");
});

it("classifies 'inside my own body' as bot_in_target", () => {
const msg = "Can't place cobblestone at (5,71,3): that cell is inside my own body and no adjacent empty cell has a solid neighbour to place against. Move me to a clearer spot first.";
assert.equal(classifyBotError(msg), "bot_in_target");
});

it("classifies 'no solid adjacent block' as no_solid_neighbor", () => {
const msg = "Can't place cobblestone at 0, 100, 0: no solid adjacent block to place against. Choose an empty space next to/above an existing block, or place a support block first.";
assert.equal(classifyBotError(msg), "no_solid_neighbor");
});

it("returns null for unrelated error strings", () => {
assert.equal(classifyBotError("Bot movement pathfinder timeout"), null);
assert.equal(classifyBotError("Mined 0/1 oak_log. Have 0 oak_log in inventory."), null);
assert.equal(classifyBotError("Can't see any quartz_ore from 28, 76, 53."), null);
});

it("returns null for empty / non-string input", () => {
assert.equal(classifyBotError(""), null);
assert.equal(classifyBotError(null), null);
assert.equal(classifyBotError(undefined), null);
assert.equal(classifyBotError(42), null);
});
});

// ── foldBotResponse: classifier integration ────────────────────────────────
// When a known spatial pattern matches, the canonical error_type is
// emitted INSTEAD of the generic bot_action_failed / bot_soft_failure,
// while preserving the original details string verbatim.
describe("foldBotResponse with spatial classifier", () => {
it("HTTP 500 + 'target space is occupied' → error_type=target_occupied", () => {
const out = foldBotResponse({
ok: false, status: 500,
body: { error: "Can't place crafting_table at 0, 70, 1: target space is occupied by prismarine. Dig that block first or choose an empty adjacent space." },
});
assert.equal(out.ok, false);
assert.equal(out.error_type, "target_occupied");
assert.match(out.details, /target space is occupied/);
});

it("HTTP 500 + 'inside my own body' → error_type=bot_in_target", () => {
const out = foldBotResponse({
ok: false, status: 500,
body: { error: "Can't place cobblestone at (0,71,0): that cell is inside my own body." },
});
assert.equal(out.error_type, "bot_in_target");
});

it("HTTP 500 + 'no solid adjacent block' → error_type=no_solid_neighbor", () => {
const out = foldBotResponse({
ok: false, status: 500,
body: { error: "Can't place cobblestone at 0, 100, 0: no solid adjacent block to place against." },
});
assert.equal(out.error_type, "no_solid_neighbor");
});

it("HTTP 500 generic error → unchanged error_type=bot_action_failed", () => {
const out = foldBotResponse({
ok: false, status: 500,
body: { error: "Bot movement pathfinder timeout" },
});
assert.equal(out.error_type, "bot_action_failed");
});
});