Skip to content

feat(subagents): programmatic dispatch API with per-dispatch controls (incremental) - #831

Open
northwang-lucky wants to merge 7 commits into
agegr:mainfrom
northwang-lucky:feat/subagent-dispatch-api
Open

northwang-lucky wants to merge 7 commits into
agegr:mainfrom
northwang-lucky:feat/subagent-dispatch-api

Conversation

@northwang-lucky

@northwang-lucky northwang-lucky commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Part of the discussion in #829 — this is the incremental of the two reference implementations (the pipeline variant is #832). Either one is ready to walk forward; whichever the maintainer prefers, we'll rework against your review (or close both without hard feelings).

What it adds (5 commits, branched from main @ 8366762):

  • lib/subagent-dispatch.ts — a versioned programmatic dispatch API (registerDispatchRuntime() -> globalThis.__piSubagentDispatch): startSubagentDispatch(parentSessionId, params) with per-dispatch tools / disallowedTools, extensions / denyExtensions (source-aware filter keys), excludeTools (reserved control names always excluded), signal / onUpdate, and lifecycle events carrying the runtime-resolved effective model/thinking/activeTools — not request echoes
  • per-dispatch persistSession: false — child session runs in-memory (nothing hits disk); the run's audit metadata is redirected to the parent session, reusing the existing pi-web:subagent custom-entry format
  • deliberately no concurrency policy: queueing and per-parent limits stay with the upstream SubagentQueue (b77a25f)

Tests: shared contract suite (subagent-dispatch*.test.mjs, subagent-tools-resolution.test.mjs) is byte-identical with the pipeline branch; this branch lands at 1064 passing (upstream 1020 + 44), tsc / eslint clean. Behavior validated live against a real model (dispatch round-trip, non-echoed effective values, in-memory run leaves no .jsonl).

Known coverage note: the profile-level persistSession=false case is pinned by a source-level assertion here; a driven behavioral test for it exists on #831 and we'll port it over if this branch becomes the chosen one.

  - Port the dispatch runtime (createDispatchRuntime /
    registerDispatchRuntime, globalThis.__piSubagentDispatch) onto the
    upstream SubagentQueue base, keeping the API contract from the
    incremental branch.
  - Concurrency gating (former G5) is intentionally absent: queueing and
    per-parent limits belong to the upstream SubagentQueue.
  - Lifecycle-event effective values use a two-step fallback (dispatch
    param -> parent state) as an interim; the profile tier ships with the
    runtime-integration slice, which must also restore the three-tier
    assertions and switch events to run-authoritative values.
  - Suites: S1 (10) and S4 (6) ported/adapted; S5 dropped with G5;
    S2/S3/S6 deferred to their own slices.
…re filter key

G2: Add request.tools (whitelist override) and request.disallowedTools
(blacklist post-merge) to StartSubagentRequest. When request.tools is
present it replaces profile.tools as the base tool list; extension tool
names are merged via withSubagentExtensionTools. disallowedTools is
applied after the merge and takes absolute precedence.

G3: Add request.extensions and request.denyExtensions to filter which
loaded extensions contribute tool names. The pipeline uses
filterExtensionsBySource() which derives stable match keys via
extensionFilterKey(): npm sources yield the package name, file-path
and auto-discovered sources yield the basename without extension
(extracted from sourceInfo.path since sourceInfo.source is "auto" for
all auto-discovered extensions). Dispatch params override profile params;
profile has no extensions/denyExtensions in upstream so dispatch is the
only filter layer.

C fix: extensionFilterKey() handles scoped npm packages
(@scope/name@version) correctly by stripping the trailing version
suffix instead of splitting on "@" which would lose the scope. For
file-path sources, basename without extension is used. The
filterExtensionsBySource() helper applies both allow and deny lists with
deny winning over allow.

Also adds request.excludeTools alongside the unconditional reserved
control name exclusion (Agent/get_subagent_result/steer_subagent).

Tests (12 cases, all drive real exported functions):
  withSubagentExtensionTools: 4 (merge/filter/dedup/empty)
  extensionFilterKey: 4 (scoped npm/unscoped npm/file path/no-ext)
  filterExtensionsBySource: 4 (path fallback/deny wins/no filter/graceful)

Mirror-style ported cases (10) that recreated production logic locally
were dropped — they would not turn red on a runtime revert and
one (:170) fossilised the old file-path semantic opposite to the C fix.
Source-inspection patterns in rpc-manager.test.mjs updated to match
the new pipeline shape (excludeTools array and baseTools variable).
…eTools in lifecycle events (G4)

- Runtime resolves effective model via three-level fallback (dispatch param →
  profile → parent) early in start(), before resourceSnapshot and initialRun
  construction.
- resourceSnapshot now carries model and thinking for audit trail.
- initialRun carries model, thinking, and activeTools so downstream consumers
  see the authoritative resolved values.
- Dispatch layer reads model/thinking/activeTools from the run object instead
  of re-resolving from params or parentState (transitional fallback removed).
- SubagentRunInfo and SubagentResourceSnapshot gain model/thinking/activeTools
  fields.
- S4 tests restored with three-level semantic: fake controller supplies
  _runValues distinct from request params, proving dispatch reads from run.
- G4 behavior tests added: thinking fallback, request thinking bypasses
  parent invalid, request model wins chain, unresolvable model rejects,
  activeTools carried on run.
- Source inspection tests verify runtime structure and dispatch read path.
…e audit trail

- Add persistSession?: boolean to StartSubagentRequest and
  SubagentDispatchParams (default true; false = in-memory only).
- When persistSession=false: SessionManager.inMemory() is used instead
  of create(), so no .jsonl file is written to disk.
- Audit metadata (SUBAGENT_META_TYPE, STATUS, RESULT) is redirected to
  the parent session when persistSession=false, preserving the dispatch
  trail after the in-memory session is garbage-collected.
- Three-level fallback: request.persistSession ?? profile.persistSession
  ?? true, aligning with upstream's profile-level persistSession field.
- S6 contract tests: persistSession forwarding through dispatch layer.
- G6 behavior tests: in-memory vs persisted session creation, audit
  metadata routing to parent sessionManager (real SDK services).
- G6 source test: structural guard for three-level fallback resolution.
- S4 edge-case assertions: empty model → effectiveModel '',
  null thinking → effectiveThinking null.
The production dispatch module mutated the request object to attach a
sessionId for test fake correlation — a side-channel from the old branch
that was never part of the public API.  Remove it and have test fakes
track run ids through their own _runIds array instead.
The ported feature branches had dropped the AGENTS.md coverage their
predecessor carried: add the file-map row for lib/subagent-dispatch.ts
plus a short section describing the programmatic dispatch entry point
and the per-dispatch parameter overrides.
  - The dispatch runtime never reads settings; concurrency policy belongs
    to the upstream SubagentQueue. Remove the interface member, the
    production binding, the fake key (kept byte-identical with the
    pipeline branch's contract suite), and the now-unused import.
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