fix(agent-runner): require explicit destination addressing, fix per-destination threading

The poll loop had a bare-text routing fallback in dispatchResultText: when
the agent produced text without <message to="..."> wrapping, it would auto-
route to the session's originating channel (via a frozen RoutingContext) or
to the single configured destination. This caused three problems:

1. Routing drift: RoutingContext was extracted once from the initial batch
   and never refreshed. When the initial batch was a null-routed cron task
   and a real chat arrived mid-query, replies were silently dropped to
   scratchpad because the frozen routing had all-null fields.

2. Cross-channel thread bleed: sendToDestination applied a single
   routing.threadId to every outbound message regardless of destination.
   In agent-shared sessions (multiple channels sharing one session), one
   channel's thread ID was stamped onto messages to a different channel.

3. Inconsistent formatting: task, webhook, and system messages had no
   origin metadata in their formatted output, so the agent couldn't tell
   which destination they came from — even when the underlying messages_in
   rows carried routing fields.

Changes:

- Remove the bare-text routing fallbacks in dispatchResultText (both the
  routing-based and single-destination shortcuts). All agent output must
  be wrapped in <message to="name">...</message>. Bare text is scratchpad.

- Update buildDestinationsSection() to require explicit wrapping for all
  groups, including single-destination. No more "no special wrapping
  needed" shortcut.

- Resolve thread_id per-destination via resolveDestinationThread(), which
  queries messages_in for the most recent message matching the target
  channel+platform. Falls back to null (top-level channel message) when
  no prior inbound exists for that destination.

- Extract originAttr() helper in formatter.ts and apply it to all message
  types. Tasks now render as <task from="dest" time="...">, webhooks as
  <webhook from="dest" source="..." event="...">, system responses as
  <system_response from="dest" ...>. The agent always sees where a
  message originated.

- Add a PreCompact shell hook (compact-instructions.ts) that outputs
  custom compaction instructions, telling the compactor to preserve
  recent message XML structure and routing metadata in the summary.
  Wired via settings.json in the .claude-shared scaffold, with a
  migration path (ensurePreCompactHook) for existing groups.

Relation to open PRs:

- #2277 (mergeRouting) becomes unnecessary — the routing fallback it
  patches no longer exists. Can be closed.
- #2327 (post-compaction destination reminder) is complementary — it
  handles the post-compaction push, this handles pre-compaction
  instructions. Both can merge independently.
- #2328 (default routing instruction) is complementary — it adds "reply
  to the from= destination" guidance to the multi-destination section.
  Compatible with the unified instruction format here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-05-07 19:47:46 +03:00
parent ba70ddf73a
commit 9db39b291d
6 changed files with 155 additions and 76 deletions

View File

@@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON =
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'bun /app/src/compact-instructions.ts',
},
],
},
],
},
},
null,
2,
@@ -71,10 +83,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
// (ensurePreCompactHook is defined after the main function.)
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
@@ -90,3 +105,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
});
}
}
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
/**
* Patch an existing settings.json to add the PreCompact hook if missing.
* Runs on every group init so pre-existing groups pick up the hook.
*/
function ensurePreCompactHook(settingsFile: string, initialized: string[]): void {
try {
const raw = fs.readFileSync(settingsFile, 'utf-8');
const settings = JSON.parse(raw);
// Check if there's already a PreCompact hook with our command.
const existing = settings.hooks?.PreCompact as unknown[] | undefined;
if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
// Add the hook, preserving existing hooks.
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
settings.hooks.PreCompact.push({
hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }],
});
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json (added PreCompact hook)');
} catch {
// Don't break init if settings.json is malformed — it'll use whatever's there.
}
}