Skip to content

feat: Prax's self-diagnosed bugs fixed, an honest prax_doctor with log health, and credential secrets kept out of traces - #259

Merged
praxagent merged 16 commits into
mainfrom
fix/prax-self-diagnosis
Oct 3, 2026
Merged

praxagent merged 16 commits into
mainfrom
fix/prax-self-diagnosis

Conversation

@praxagent

Copy link
Copy Markdown
Owner

Prax diagnosed three issues in itself and asked for them to be fixed. This PR fixes them, along with what the same classes of bug turned up. Every finding was adversarially verified before it was fixed, and the fixes were reviewed and re-verified twice.

0. Recovered: argument hashes in traces (missed #250)

The argument-hash commit was pushed to feat/wire-record-check after #250 had already been squash-merged, so it never reached main. It is cherry-picked here. Fixes on top:

  • What it records. requested_args_sha256 (the model's raw arguments, matching the wire record) stays. "Changed before running" is now an args_changed flag from a loose comparison done in memory. It drops expected_observation, ignores filled-in defaults, and tolerates validation's conversions ("5"→5, "yes"→True, int→float).
  • No false positives on ordinary calls. The first version flagged every call that had defaults.
  • A model-sent "1e999999999" can't freeze the process. Numbers are compared as Decimals.
  • Parallel calls to the same tool get separate spans. The trace recorder now dedups by parent run, not by tool name.
  • Wire matching is two-pass. A mismatch can no longer steal its honest sibling's match.

1. Prax's self-diagnosis, fixed properly

Prax found Fix
OTelToolCallback.on_tool_end TypeError on every tool call Preview from the message content (not str(ToolMessage)). span.end() always runs: previously every successful tool span was lost. Nested wrapper layers no longer triple-count TOOL_CALLS. The same str(ToolMessage) bug in the trace recorder is why trace reports showed content='…'. Unit tests use real ToolMessages through a real ToolCall
Unknown channel: browser #content is missing too, and core channels existed only because TeamWork created them once. Now there is one registry of every channel Prax posts to (teamwork_channels.PRAX_CHANNELS): ensured at startup, ensured lazily once per name, with per-name locks. An AST drift guard fails CI if a call site posts to an unregistered name. The warning is kept, not downgraded to debug as proposed (it was the only signal), but emitted once per name
ensure_branch_channel uses self._enabled It now uses the enabled property. The test stub bypassed the real constructor, which is why tests passed. It now uses the constructor, and a real-client regression test was added
— (found in the sweep) The legacy #claude-code interjection handler raised ImportError; it now falls through cleanly

2. prax_doctor reports correctly

  • Readings came from os.environ, which pydantic never fills, so the doctor said "TeamWork: not configured" and FAILed the LLM check. It now reads settings. The LLM check builds each enabled tier through the real factory (verified to make no network call).
  • The plugin check called methods that don't exist. It now uses real APIs, including import failures that previously reached no tool. plugin_list shows load failures.
  • The spoke check was removed. It could never fail independently.
  • The health check is computed fresh.
  • The tool error rate was always 0% or 100% because successes were never recorded. Successes now go to an in-memory counter, not the event store, so they can't evict rare alerts or flood the Health tab. The rate covers this process only, so a restart doesn't read as 100%. The telemetry file is compacted.
  • Log health (new, LOG_HEALTH_ENABLED, default off). An in-process handler groups WARNING+ records by code location. It keeps templates only for Prax's own call sites, never raw messages (logs carry user data). Workspace paths are scrubbed and locations sanitised.
  • system_status no longer quotes raw log lines and reads only a bounded tail.

3. Credential redaction (found by review)

  • Before: under the default BROWSER_SECRETS_OUT_OF_CONTEXT=false, browser_login returns username=…\npassword=…. That text reached:
    • OTel previews (live for the first time once the TypeError was fixed);
    • trace summaries;
    • the spoke log;
    • the governance audit, which _write_trace writes into the workspace trace.log (committed to git, searchable, in TeamWork's file browser).
      The password also came back as browser_fill(text=…) arguments.
  • Now:
    • Output from the tools that return secrets is withheld at every sink. The other credential tools keep their outcomes in the audit.
    • A turn-scoped secret set, never persisted and never in a repr, masks those values wherever they reappear: arguments, logs, audit, trace.log, and spoke/sub-agent answers.
    • It survives the turn-end drain, so a timed-out turn's worker stays masked.
  • Reverted: registering spoke "role agents". It would have switched on TeamWork activity logging of spoke tool output, and added 11 synchronous PATCHes to every turn. "Health Monitor" is registered (its alerts land) but kept out of reset_all_idle.
  • Known limits (documented):
    • exact-string matching only;
    • values under 4 characters are not masked;
    • argument hashes stay unsalted, to remain comparable with the wire record.
    • Recommend BROWSER_SECRETS_OUT_OF_CONTEXT=true in production, so the model never sees the password at all.

Tests

  • make ci green: 4342 passed (up from 4069 on main). No secrets-proxy requests during the run.
  • New suites:
    • test_tool_callback_outputs, test_channel_registry, test_doctor, test_log_health, test_turn_secrets, test_credential_output_redaction, test_system_status, test_coding_channel_interjection, plus args-hash and wire-check cases.
    • Key tests were mutation-checked: they fail with the fix reverted.
  • tests/conftest.py pins TEAMWORK_URL="". Local make ci no longer waits about 30 s per create_app for a TeamWork that isn't running: 23 min → 15 min.

Not in this PR (follow-ups)

  • read_logs still reads the whole log file.
  • The "Blocked plugins" section of plugin_list prints raw text.
  • GET/DELETE /teamwork/claude-code/sessions import removed helpers.
  • 51 health-monitor tests break if a developer's .env sets HEALTH_MONITOR_ENABLED=false.

After merge

Deploy. Optionally set LOG_HEALTH_ENABLED=true and BROWSER_SECRETS_OUT_OF_CONTEXT=true in production.

… wrapped tools once

on_tool_end receives a ToolMessage for every ToolCall invoke (every ToolNode
call), not a str:

- OTelToolCallback sliced it, raising TypeError after the span was popped, so
  span.end() never ran and every successful tool span was lost. The preview
  now goes through tool_output_text and end() runs in a finally (on_tool_error
  too).
- GraphCallbackHandler stored str(ToolMessage), a pydantic repr, as every tool
  node's summary and live preview. It now stores the content, and a tool that
  handled its own failure (an error ToolMessage, which ends via on_tool_end)
  completes its node as "failed".

Governance and context binding wrap a tool in same-named StructuredTools that
each fire on_tool_start as a child of the layer above, so OTelToolCallback
counted TOOL_CALLS and opened a span once per layer. Inner layers are now
recognised by parent run + name and skipped.

tool_output_text (prax/agent/message_text.py) is the shared, never-raising
flattener for tool results of any shape.
… stop crashing

The browser and content spokes posted to #browser and #content, which
neither TeamWork's project defaults nor Prax's startup ever created, so
every such post was dropped with "Unknown channel". The spoke role agents
were never registered either, so their posts were unattributed and their
status updates were no-ops.

- prax/services/teamwork_channels.py: one import-free registry of the
  channels Prax posts to (PRAX_CHANNELS) and the role agents it posts as
  (PRAX_ROLE_AGENTS).
- teamwork_hooks: ensure_prax_channels() ensures the whole registry at
  startup (ensure_mirror_channels kept as an alias); register_role_agents()
  registers every role, one failure not costing the rest; reset_all_idle
  covers them all.
- TeamWorkClient: a registry channel the project lacks is ensured once on
  first post (recovers a failed startup ensure or an older project);
  unknown channels warn once per name, then count at DEBUG; the same
  lookup now backs forward_external_message. ensure_channels failures and
  names missing from its response log at WARNING.
- ensure_branch_channel read self._enabled, which the client never
  assigns, so it raised AttributeError on every real instance; it now uses
  the enabled property, and post_branch_update keeps the whole operation
  inside its try and reports an undelivered post as False.
- The legacy coding-agent interjection handler imported bridge helpers
  that were removed with the coding-agent CLIs; it now falls through to
  Prax instead of replying with a generic error.
- tests/test_channel_registry.py: AST drift guard over channel and role
  literals, plus lazy-ensure, warning and startup-hook tests. The branch
  channel stub now goes through the real constructor.
prax_doctor read TEAMWORK_URL, provider keys and FLASK_SECRET_KEY from
os.environ, but pydantic loads .env without exporting it, so a host-process
deployment was told "TeamWork: not configured" and failed the LLM check while
both were set. Every check now reads the settings object:

- TeamWork uses settings.teamwork_active and says why it is off (empty URL,
  or the deprecated TEAMWORK_ENABLED=false overriding a set URL, a warning).
- LLM builds the model for every enabled tier through build_llm and FAILs
  with the factory's error. Construction makes no network call (tested), and
  the probes run inside llm_factory.probe_builds() so they are not recorded
  as tier choices in the turn's trace.
- Plugins use the loader's real state through a new
  PluginLoader.health_report(), which system_status shares (both probed
  methods that do not exist, so failures were never reported and the count
  was "?"). It reports blocked plugins, plugins whose load raised (kept in
  a separate _load_failures, since load_errors is shown as "blocked"), and
  loaded plugins with failure_count > 0. A registry rolled_back status is
  not checked: rollback() reloads at once and the reload re-activates it.
- The spoke check is gone: build_all_spoke_tools() imports every registered
  spoke unguarded while building the tool list prax_doctor is in, so it
  could never fail while the doctor could run.
- The health monitor is read through a new get_check(max_age_s=300), the
  staleness rule get_health_status already used, instead of
  get_last_check(), which is None for the first nine turns.

The tool error rate was 0% or 100%: governed_tool recorded TOOL_ERROR on the
hub but never TOOL_SUCCESS, so the denominator held only errors. Hub
successes are now recorded (spoke layers record neither, so a delegation is
one call).

New, behind LOG_HEALTH_ENABLED (default off): prax/services/log_health.py, a
root logging handler installed at app startup that counts WARNING+ records
by (level, logger, file:line). It never stores the formatted message or
exception text; it keeps the %-template only when the record has args, and
caps the table at 500 sites. prax_doctor warns when one site reaches
LOG_HEALTH_WARN_COUNT and lists the top LOG_HEALTH_TOP_N, without reading
the log file.
…hat ran

Tool spans gain requested_args_sha256 (from the model's response, matched by
tool_call_id) and args_sha256 (from the innermost tool start, so a wrapper that
rewrites arguments shows up). Hashes only, in the wire record's canonical form.
check_wire_record now matches on them and reports ARGS DIFFER and CHANGED
BEFORE RUNNING; older traces still match on name and time.
Registering a role switches on TeamWork's activity log, live output and
status for it: TeamWorkClient skips those calls for unregistered agents.
Registering the spoke roles therefore streamed spoke tool output, including
browser_login results, into TeamWork's persistent activity log, and grew
reset_all_idle from 5 to 16 synchronous PATCHes on every turn's critical
path, flipping concurrently running turns' spokes to idle.

- PRAX_ROLE_AGENTS is back to the four core roles, so startup registers
  exactly what it did before (the orchestrator plus Planner, Researcher,
  Executor, Auditor) and reset_all_idle patches the original five. The
  per-agent try/except stays, so one failed create_agent no longer aborts
  TeamWork startup.
- The channel registry, ensure_prax_channels, the lazy ensure and the
  once-per-name warning are unchanged.
- TeamWorkClient: the single lock was held across the lazy ensure request
  (15 s timeout), so posts to unrelated channel names and the drop counter
  waited behind it, on Discord's event loop for forwarded messages. Each
  registry name now has its own ensure lock, built once in __init__, and the
  drop bookkeeping has a separate lock that is never held across I/O.
- Tests: the role drift guard now pins the core roles and fails if any spoke
  role is registered, through the registry or a literal create_agent call
  in the package or app.py. A new test checks that a slow lazy ensure of
  one channel does not stall posts to other names; it fails on the old
  single lock.
- README lists the channels from the registry and the actual role agents.
- check_layers allowlist: the grandfathered teamwork_hooks import moved to
  line 66.
…ial outputs withheld

Tool spans were deduplicated by tool NAME, so two parallel calls to the same
tool folded into one span (one call's hashes, the other call missing). Wrapper
layers are now recognised by run chain: a start whose parent run is an open
run of the same tool maps to that span; anything else opens its own.

"Changed before running" compared the model's raw-argument hash with the hash
of the innermost layer's inputs, which LangChain has validated (defaults
filled, values coerced, expected_observation popped), so it fired on ordinary
calls. The model's arguments are now kept in memory for the life of the call
and compared loosely with every layer's inputs; a missing or different
requested value sets a new span flag, args_changed, which check_wire_record.py
reports. args_sha256 stays, documented as post-validation and not comparable.

Credential tools (hard_floors.CREDENTIAL_TOOLS, public, independent of
HARD_FLOORS_ENABLED) return passwords in plain text. Their output is withheld
and their argument values masked in the OTel span attributes, trace summaries,
TeamWork live output and activity, and the spoke runner's tool log.
…scrub log health

Health telemetry: tool successes are now counted in memory (a deque of
timestamps pruned to the 60-minute stats window), never stored as events.
At tool-call rate they evicted the rare alerts (LLM_ERROR, TURN_TIMEOUT,
SPOKE_FAILURE) from the 2000-event cap, so the health check read healthy,
flooded TeamWork's recent-events feed, and grew the JSONL file without
bound. record_event(TOOL_SUCCESS) is routed to the counter too. Because
successes are not persisted, the error rate covers only events since the
process started, so reloaded errors no longer read as 100% after a
restart; the stats carry the rate's own counts and the alert quotes them.
The file is bounded: rewritten atomically on load when rows were dropped,
and by record_event past twice the memory cap.

Plugin health: a non-builtin plugin's key, manifest and exception text are
plugin-authored and reached prax_doctor and system_status, which carry no
untrusted-content banner. Those plugins now appear as a sanitised, capped
key plus a fixed category pointing at plugin_list. Builtin plugins keep
their message.

Log health: templates are kept only for Prax's own call sites (library
templates such as werkzeug's carry the client IP and a timestamp);
locations never name a user (<workspace>/..., <external>/<basename>);
uninstall() detaches from the logger install() used and does not forget a
handler that is still installed elsewhere.

Tests: the app-startup test no longer reloads app (it left the flag-on
settings behind, so later create_app() calls installed a handler nobody
removed); conftest pins TEAMWORK_URL="" so create_app() does not wait
~30 s for a TeamWork that is not running; the doctor's "checked Ns ago"
assertion tolerates a slow clock.
…e matching is two-pass, system_status stops quoting log lines

- plugin_list gains a "Failed to load" section from the loader's new
  get_load_failures() accessor: health_report sends the model there for a
  withheld plugin's crash detail, and no tool showed it. Labelled as
  plugin-reported text, each entry flattened to one line and capped at 300
  characters. It also no longer returns early when nothing is loaded but
  plugins were blocked or crashed.
- The Health Monitor is registered at startup again, so its log_activity
  alerts are no longer dropped by TeamWorkClient. RoleAgent.has_status keeps
  it out of reset_all_idle, which stays at the orchestrator plus the four core
  roles (5 PATCHes); a drift guard fails if a status-less role is ever given a
  status or live output.
- check_wire_record matches in passes: every exact (name + hash) match first,
  then hashless legacy spans, then reports the rest, so an ARGS DIFFER can no
  longer consume a parallel sibling's exact span and misreport it too.
- system_status reports recent errors as a count from the last 256 KB of the
  log (no full readlines), plus log health's busiest ERROR call sites
  (location and template only) when it is on, and points at read_logs. Raw
  [ERROR] lines, which can carry plugin-written text, are no longer quoted.
…back as arguments

- log_action (every audit entry -> workspace trace.log + debug log): a
  credential tool's argument values are masked and its own output withheld;
  governance verdicts on it are kept (from_tool=False). Errors from a
  credential tool record the exception type only.
- New prax/agent/turn_secrets.py: when a CREDENTIAL_TOOLS call returns, the
  values of its secret-named key=value / key: value lines are registered on
  the turn's TurnGovernanceState (empty at begin_turn, cleared at drain, never
  persisted, values < 4 chars ignored, repr/JSON-escaped forms included).
  browser_login, browser_credentials and browser_fill_login also register the
  stored values they read. scrub() masks them in the OTel input/output/error
  previews, the trace running node, output and error summaries, the spoke and
  sub-agent tool logs, governance's INFO logs and approval summaries, audit
  args/results, and every entry of the orchestrator's trace.log.
- arguments_differ: bools compare through pydantic's own lax bool parsing;
  integers compare exactly before any float fallback.
- Spoke tool log: the failure heuristic reads the real output; only the
  display is redacted. The sub-agent reuses this logger instead of its old
  unredacted copy.
…thout blow-up, audit keeps login outcomes

- The turn's secret set is no longer cleared when the audit log drains: a
  timed-out or stopped turn's worker can still run tools on that state. It
  goes away with the state, which begin_turn replaces; never in a repr.
- arguments_differ compares numbers as Decimals (a model-sent "1e999999999"
  used to become a billion-digit int and freeze the process) and as floats
  when validation produced a float (not a rewrite).
- The audit withholds output only for tools that return a secret
  (browser_login, browser_credentials); the other credential tools keep their
  outcomes, and the domain stays in the args, all scrubbed.
- Spoke and sub-agent answers are scrubbed at their sinks (logs, span summary,
  live output, channel post); a spoke failure keeps its traceback, scrubbed.
- log_health locations keep only path characters (a workspace plugin's
  directory name is plugin-authored).
@praxagent
praxagent merged commit 7dcc883 into main Oct 3, 2026
1 check passed
@praxagent
praxagent deleted the fix/prax-self-diagnosis branch October 3, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant