15 July 2026

Giving a Multi-Agent Framework Shell Access — What We Fixed and Why It Matters

Background

jiuwenclaw is a Chinese-origin multi-agent framework built on top of the openjiuwen agent-core. It supports a "team mode" where a leader agent coordinates a group of specialist teammates, each with their own skills, tools, and workspace.

This post documents three connected bugs we found and fixed, all triggered by a single user request: "Can the agent run shell commands the way Gemini CLI does?"


Problem 1: The React Loop (Agent Generates Text Instead of Tool Calls)

Symptom

After asking a question that required inspecting the machine (e.g. "what WiFi networks can you see?"), the agent entered an infinite loop. Each iteration the LLM produced a natural-language response describing how it would run a command — but never actually ran one. The runner log showed finish_reason: stop on every turn, meaning no tool was ever called.

Root Cause

The react loop in the openjiuwen harness re-invokes the LLM when the response has no tool call (finish_reason != tool_calls). The LLM was generating text because no shell tool existed in the agent's tool card list.

The mcp_exec_command tool was already implemented in command_tools.py and wired into the SkillDevService, but the team/agent mode uses a different tool registration path. In interface_deep.py, _get_tool_cards() built the list of available tools — and mcp_exec_command was never added to it.

Fix

Register mcp_exec_command in _get_tool_cards():

# jiuwenclaw/agentserver/deep_agent/interface_deep.py
from jiuwenclaw.agentserver.tools.command_tools import mcp_exec_command

def _get_tool_cards(self, ...) -> list[ToolCard]:
    ...
    # After wiki tools:
    if not Runner.resource_mgr.get_tool(mcp_exec_command.card.id):
        Runner.resource_mgr.add_tool(mcp_exec_command)
    tool_cards.append(mcp_exec_command.card)
    return tool_cards

This is the pattern used by all other tools in that method. Once registered, the LLM can actually call the tool instead of narrating about it.


Problem 2: The Sandbox Restriction Blocked Useful Commands

Symptom

After fixing the registration, agents could call mcp_exec_command — but any workdir outside the agent workspace raised a ValueError, producing [ERROR]: workdir is outside project workspace. This prevented running commands like ip addr in /tmp or inspecting files anywhere outside the agent's sandbox.

Root Cause

_resolve_command_workdir unconditionally called:

candidate.relative_to(project_root)  # raises ValueError if outside workspace

This was appropriate for a sandboxed skill dev environment but too restrictive for an agent with system-level access.

Fix

Introduce a MCP_EXEC_COMMAND_SANDBOX environment variable (default false) that opts into the restriction rather than enforcing it always:

def _is_workdir_sandbox_enabled() -> bool:
    raw = os.getenv("MCP_EXEC_COMMAND_SANDBOX", "false").strip().lower()
    return raw in ("1", "true", "yes", "on")

def _resolve_command_workdir(workdir: str) -> Path:
    project_root = get_agent_workspace_dir()
    candidate = Path(workdir) if workdir else project_root
    if not candidate.is_absolute():
        candidate = project_root / candidate
    candidate = candidate.resolve()
    if _is_workdir_sandbox_enabled():
        candidate.relative_to(project_root)  # raises ValueError if outside workspace
    return candidate

The switch is documented in .env:

# mcp_exec_command: restrict workdir to agent workspace (true) or allow any path (false)
MCP_EXEC_COMMAND_SANDBOX=false

Problem 3: Workspace Files Written in Chinese Despite preferred_language: en

Symptom

After the agent's workspace was initialised, the generated files (AGENT.md, SOUL.md, HEARTBEAT.md, IDENTITY.md) were written in Chinese, even though the config contained preferred_language: en.

Root Cause — Tracing the Data Flow

The language setting travels through three layers before it reaches the template selector:

  1. Configpreferred_language: en
  2. load_team_spec_dict → builds per-agent WorkspaceSpec dicts
  3. WorkspaceSpec.language → passed to get_workspace_schema(language) → selects EN or CN template

The bug was in step 2. _DEFAULT_AGENT_WORKSPACE was defined as:

_DEFAULT_AGENT_WORKSPACE = {"stable_base": True}

No language key. When this dict was used to construct each agent's workspace spec via merged.setdefault("workspace", deepcopy(default_workspace)), the resulting spec had no language, so WorkspaceSpec.language fell back to its model default — "cn".

The preferred_language value was read in load_team_spec_dict but only assigned to spec_dict["language"] (the top-level field) — it was never propagated into the per-agent workspace dicts.

Fix

Thread preferred_language as a language parameter through _build_agents_config, then inject it into every workspace spec via setdefault (respecting any explicit per-agent override):

def _build_agents_config(
    team_raw: dict[str, Any], config_base: dict[str, Any], language: str = "zh"
) -> dict[str, Any]:
    default_model = _build_default_model_dict(config_base)
    default_workspace, max_iterations, completion_timeout = _build_agent_defaults()
    default_workspace.setdefault("language", language)  # ← inject here

    for agent_key, raw_agent_config in agents_raw.items():
        agent_config = dict(raw_agent_config) if isinstance(raw_agent_config, dict) else {}
        if "workspace" in agent_config and isinstance(agent_config["workspace"], dict):
            agent_config["workspace"].setdefault("language", language)  # ← and per-agent
        ...

Also propagate to the team-level workspace spec:

# in load_team_spec_dict:
resolved_lang = str(config_base.get("preferred_language", "zh")).strip().lower()
agents = _build_agents_config(team_raw, config_base, language=resolved_lang)
...
workspace_spec = _build_workspace_spec(team_raw)
if workspace_spec is not None:
    workspace_spec.setdefault("language", resolved_lang)
    spec_dict["workspace"] = workspace_spec

Using setdefault throughout means an agent that explicitly configures workspace: {language: zh} won't have it overridden.


Skill File

To guide the agent on what commands are available and how to use them, a skill was created at:

~/.jiuwenclaw/agent/jiuwenclaw_workspace/skills/system-commands/SKILL.md

The allowed_tools frontmatter field in jiuwenclaw's skill system is advisory — it hints to the LLM which tools the skill uses. Actual tool availability is determined by what's registered in _get_tool_cards(). The skill body includes common command patterns for networking, hardware inspection, process listing, and log tailing.


Unit Tests

test_command_tools.py — 19 tests

TestSandboxSwitch (3 tests): verifies _is_workdir_sandbox_enabled() reads the env var correctly, including case-insensitive truthy/falsy parsing for true/True/TRUE/1/yes/on and false/False/FALSE/0/no/off.

TestResolveCommandWorkdir (6 tests): verifies that:
- Empty workdir falls back to agent workspace
- Relative paths are resolved under the workspace
- Sandbox off: absolute paths outside workspace are allowed
- Sandbox on: absolute paths outside workspace raise ValueError
- Sandbox on: paths inside workspace (including root) are allowed

test_team_config_loader.py — 8 new tests in TestLanguagePropagation

  • preferred_language: en sets spec["language"] == "en" at the top level
  • Absent preferred_language defaults to "zh"
  • "en" propagates to each agent's workspace spec
  • "en" propagates to all agents when multiple agents are configured
  • "en" is injected into explicit per-agent workspace dicts (that set other keys)
  • An explicit workspace: {language: zh} on an agent is not overridden
  • "en" propagates to the team-level workspace spec
  • An explicit language: zh in the team workspace spec is not overridden

Key Architectural Learnings

  1. _get_tool_cards() is the source of truth for tool availability in team/agent mode — not get_mcp_tools(), not skill allowed_tools. Any tool that should be callable must be explicitly added here.

  2. The react harness loops on finish_reason != tool_calls — if the model can't call a tool (because none are registered for the task), it will narrate endlessly. The fix is always to provide the right tool, not to adjust the loop logic.

  3. setdefault is the right pattern for propagating config defaults — it lets outer layers provide defaults while inner (explicit) config takes precedence.

  4. MCP_EXEC_COMMAND_SANDBOX follows the same bool-string convention as other env vars1/true/yes/on enable, everything else (including absence) disables.


Files Changed

File Change
jiuwenclaw/agentserver/deep_agent/interface_deep.py Register mcp_exec_command in _get_tool_cards()
jiuwenclaw/agentserver/tools/command_tools.py Add _is_workdir_sandbox_enabled(), make sandbox conditional
jiuwenclaw/agentserver/team/config_loader.py Propagate preferred_language into all workspace specs
tests/unit_tests/agentserver/test_command_tools.py New — 19 tests for sandbox switch and workdir resolution
tests/unit_tests/agentserver/test_team_config_loader.py 8 new TestLanguagePropagation tests + updated 1 existing assertion
~/.jiuwenclaw/agent/.../skills/system-commands/SKILL.md New skill providing shell command guidance
~/.jiuwenclaw/config/.env Document MCP_EXEC_COMMAND_SANDBOX=false

No comments:

Post a Comment