@signalridge/pi-subagents 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,6 @@ import {
18
18
  SessionManager,
19
19
  SettingsManager,
20
20
  } from "@earendil-works/pi-coding-agent";
21
- import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
22
21
  import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tiers.js";
23
22
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
24
23
  import { createAskGate } from "./ask-tools.js";
@@ -29,10 +28,12 @@ import { detectEnv } from "./env.js";
29
28
  import { formatGateVerdict, type GateExec, runGate, workspaceFingerprint } from "./gate.js";
30
29
  import {
31
30
  INTERNAL_AGENT_CONFIG_OVERRIDE,
31
+ INTERNAL_PARENT_POLICY_SNAPSHOT,
32
32
  type InternalAgentConfigOverride,
33
+ type ParentPolicySnapshot,
33
34
  } from "./internal-run.js";
34
35
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
35
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
36
+ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resolver.js";
36
37
  import { checkModelScope } from "./model-scope.js";
37
38
  import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
38
39
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
@@ -40,8 +41,6 @@ import { shutdownAndDisposeSession } from "./session-lifecycle.js";
40
41
  import { preloadSkills } from "./skill-loader.js";
41
42
  import { createSupervisorTool } from "./supervisor.js";
42
43
  import type { SubagentType, ThinkingLevel } from "./types.js";
43
- import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
44
- import { resolveWorkflowTier } from "./workflow-tiers.js";
45
44
 
46
45
  /**
47
46
  * Tool names registered by THIS extension. Single source of truth so the
@@ -507,14 +506,26 @@ export interface RunOptions {
507
506
  isolated?: boolean;
508
507
  inheritContext?: boolean;
509
508
  thinkingLevel?: ThinkingLevel;
510
- /** Semantic workflow tier; resolved here rather than by workflow callers. */
511
- tier?: WorkflowTier;
512
509
  /**
513
- * User-named model tier for an ordinary spawn. Resolved here so the top-level
514
- * Agent tool, nested delegation, the scheduler and cross-extension RPC all get
515
- * the same precedence and the same fail-closed errors from one place.
510
+ * User-named model tier. Resolved here so the top-level Agent tool, nested
511
+ * delegation, the scheduler, cross-extension RPC and managed workflow calls
512
+ * all get the same precedence and the same fail-closed errors from one place.
516
513
  */
517
514
  agentTier?: string;
515
+ /**
516
+ * Refuse the implicit `defaultModel`-then-parent fallback when no tier
517
+ * applies, and reach the shipped fallback tier instead.
518
+ *
519
+ * Managed workflow calls set this so that every managed spawn carries a named
520
+ * policy with a durable resolution snapshot, rather than an unnamed model
521
+ * nobody chose for it. Note what this does *not* do: the shipped fallback is
522
+ * `medium`, whose profile inherits, so on an unconfigured host the model is
523
+ * still the parent session's. What the flag buys is that the choice has a
524
+ * name, a recorded thinking level, and a scope check — and that a user who
525
+ * sets `agentTiers.defaultTier` to "none" gets a hard refusal here instead of
526
+ * silent inheritance.
527
+ */
528
+ requireAgentTier?: boolean;
518
529
  /** Optional toolset hint forwarded by managed workflow callers. */
519
530
  toolset?: string;
520
531
  /** Additional tool names denied by the caller, merged with agent frontmatter. */
@@ -546,10 +557,15 @@ export interface RunOptions {
546
557
  /** Called on streaming text deltas from the assistant response. */
547
558
  onTextDelta?: (delta: string, fullText: string) => void;
548
559
  onSessionCreated?: (session: AgentSession) => void;
549
- /** Called after pi-subagents resolves a semantic workflow tier. */
550
- onTierResolved?: (snapshot: WorkflowTierResolutionSnapshot) => void;
551
- /** Called after pi-subagents resolves a user-named agent tier. */
552
- onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot) => void;
560
+ /**
561
+ * Called after pi-subagents resolves a user-named agent tier.
562
+ *
563
+ * `modelLabel` is the short display name of the model the tier pinned, and is
564
+ * absent when the profile inherits. It rides along rather than living on the
565
+ * snapshot because the snapshot is a durable policy record that a managed
566
+ * tombstone persists and revalidates; a cosmetic label does not belong in it.
567
+ */
568
+ onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot, modelLabel?: string) => void;
553
569
  /** Called at the end of each agentic turn with the cumulative count. */
554
570
  onTurnEnd?: (turnCount: number) => void;
555
571
  /**
@@ -568,6 +584,8 @@ export interface RunOptions {
568
584
  * JSON/public spawn field; the generation wizard is the only issuer.
569
585
  */
570
586
  readonly [INTERNAL_AGENT_CONFIG_OVERRIDE]?: InternalAgentConfigOverride;
587
+ /** Parent model/thinking captured when a managed spawn was allocated. */
588
+ readonly [INTERNAL_PARENT_POLICY_SNAPSHOT]?: ParentPolicySnapshot;
571
589
  /**
572
590
  * Reopen an existing conversation from this session file instead of starting
573
591
  * a new one. Package-internal: the only issuer is the `@handle` mention
@@ -765,41 +783,49 @@ export async function runAgent(
765
783
  inheritContext: internalOverride.inheritContext,
766
784
  }
767
785
  : loadedAgentConfig;
768
- const parentThinking = options.parentThinking ?? (() => {
769
- const level = options.pi.getThinkingLevel?.();
770
- return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
771
- ? level
772
- : undefined;
773
- })();
774
- // Resolve the semantic tier before the first await. Managed callers persist the
775
- // immutable policy snapshot from onTierResolved before provider work begins.
776
- const tierResolution = options.tier
777
- ? resolveWorkflowTier({
778
- tier: options.tier,
779
- agentConfig,
780
- directModel: options.model,
781
- thinkingOverride: options.thinkingLevel,
782
- parentModel: ctx.model,
783
- parentThinking,
784
- modelRegistry: ctx.modelRegistry,
785
- })
786
- : undefined;
787
- if (tierResolution?.snapshot) options.onTierResolved?.(tierResolution.snapshot);
788
-
789
- // Agent tiers resolve in the same place and before the same await, so every
790
- // spawn path shares one precedence and one set of fail-closed errors. A tier
791
- // that applies decides model and thinking outright: it is current policy,
792
- // while an agent's legacy `model:`/`thinking:` frontmatter is the older, weaker
793
- // statement of the same thing. Throwing here refuses the spawn rather than
794
- // quietly running a model the caller did not choose.
786
+ const parentPolicySnapshot = options[INTERNAL_PARENT_POLICY_SNAPSHOT];
787
+ const parentModel = parentPolicySnapshot ? parentPolicySnapshot.model : ctx.model;
788
+ const parentThinking = parentPolicySnapshot
789
+ ? parentPolicySnapshot.thinking
790
+ : options.parentThinking ?? (() => {
791
+ const level = options.pi.getThinkingLevel?.();
792
+ return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
793
+ ? level
794
+ : undefined;
795
+ })();
796
+ // One resolver, one precedence, one set of fail-closed errors, for every
797
+ // spawn path. It runs before the first await so a managed caller can persist
798
+ // the immutable snapshot before any provider work begins.
795
799
  const agentTierResolution = resolveAgentTier({
796
800
  requestedTier: options.agentTier,
801
+ // The same flag that forbids the parent fallback below is what lets the
802
+ // resolver reach its shipped fallback, so the two can never disagree about
803
+ // whether this spawn has a tier.
804
+ requireTier: options.requireAgentTier === true,
797
805
  agentConfig,
798
- parentModel: ctx.model,
806
+ parentModel,
799
807
  parentThinking,
800
808
  modelRegistry: ctx.modelRegistry,
801
809
  });
802
- if (agentTierResolution.snapshot) options.onAgentTierResolved?.(agentTierResolution.snapshot);
810
+ const agentTierSnapshot = agentTierResolution.snapshot;
811
+ if (options.requireAgentTier === true && !agentTierSnapshot) {
812
+ throw new Error(
813
+ `No agent tier selected for "${type}". A managed workflow call must name a tier, ` +
814
+ "the agent must declare one, or agentTiers must offer a default; " +
815
+ "inheriting the parent session's model is not a policy this call can fall back to.",
816
+ );
817
+ }
818
+ if (agentTierSnapshot) {
819
+ // A tier owns model resolution, so it owns the label the UI shows for the
820
+ // spawn: no caller can compute one, because none of them resolved the
821
+ // model. A profile that inherits pinned nothing, so it gets no label — the
822
+ // agent is running the parent's model, which the UI shows by omission.
823
+ const tierModelLabel =
824
+ agentTierSnapshot.configuredModel === "inherit" || !agentTierResolution.model
825
+ ? undefined
826
+ : shortModelLabel(agentTierResolution.model);
827
+ options.onAgentTierResolved?.(agentTierSnapshot, tierModelLabel);
828
+ }
803
829
 
804
830
  // Resolve working directory: worktree override > parent cwd
805
831
  const effectiveCwd = options.cwd ?? ctx.cwd;
@@ -1021,26 +1047,24 @@ export async function runAgent(
1021
1047
  }
1022
1048
  }
1023
1049
 
1024
- // `options.model` stays highest: it is a resolved Model handed over by a
1025
- // programmatic caller, which is a more explicit act than naming a tier.
1026
- const model = options.model ?? agentTierResolution.model ?? tierResolution?.model ?? resolveDefaultModel(
1027
- ctx.model, ctx.modelRegistry, agentConfig?.model,
1028
- );
1029
- const thinkingLevel = agentTierResolution.snapshot
1050
+ // A resolved tier owns both values. In particular, a pre-resolved parent or
1051
+ // legacy model/thinking option from a caller cannot bypass it.
1052
+ const model = agentTierSnapshot
1053
+ ? agentTierResolution.model
1054
+ : options.model ?? resolveDefaultModel(parentModel, ctx.modelRegistry, agentConfig?.model);
1055
+ const thinkingLevel = agentTierSnapshot
1030
1056
  ? agentTierResolution.thinkingLevel
1031
- : options.tier !== undefined
1032
- ? tierResolution?.thinkingLevel
1033
- : options.thinkingLevel ?? agentConfig?.thinking;
1057
+ : options.thinkingLevel ?? agentConfig?.thinking;
1034
1058
 
1035
- if (agentTierResolution.snapshot) {
1036
- const { configuredModel, source } = agentTierResolution.snapshot;
1059
+ if (agentTierSnapshot) {
1060
+ const { configuredModel, source } = agentTierSnapshot;
1037
1061
  const scopeVerdict = checkModelScope({
1038
1062
  model,
1039
1063
  cwd: ctx.cwd,
1040
1064
  modelRegistry: ctx.modelRegistry,
1041
- // A tier the caller named is a runtime choice by the model, which is what
1042
- // scopeModels exists to police; a tier that came from the agent file or
1043
- // the configured default is the user's own config and only warns.
1065
+ // scopeModels polices a tier chosen per dispatch, by the host model or by
1066
+ // a workflow script alike; a tier from the agent file or the configured
1067
+ // default is standing config and only warns.
1044
1068
  callerSupplied: source === "call",
1045
1069
  agentLabel: agentConfig?.displayName ?? type,
1046
1070
  modelInput: configuredModel === "inherit" ? undefined : configuredModel,
@@ -1048,20 +1072,6 @@ export async function runAgent(
1048
1072
  if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
1049
1073
  if (scopeVerdict.kind === "warn" && ctx.hasUI) ctx.ui.notify(scopeVerdict.message, "warning");
1050
1074
  }
1051
-
1052
- if (options.tier) {
1053
- const configuredModel = tierResolution?.snapshot?.configuredModel;
1054
- const scopeVerdict = checkModelScope({
1055
- model,
1056
- cwd: ctx.cwd,
1057
- modelRegistry: ctx.modelRegistry,
1058
- callerSupplied: false,
1059
- agentLabel: agentConfig?.displayName ?? type,
1060
- modelInput: configuredModel === "inherit" ? undefined : configuredModel,
1061
- });
1062
- if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
1063
- if (scopeVerdict.kind === "warn" && ctx.hasUI) ctx.ui.notify(scopeVerdict.message, "warning");
1064
- }
1065
1075
  const disallowedSet = (() => {
1066
1076
  const names = [...(agentConfig?.disallowedTools ?? []), ...(options.excludeTools ?? [])];
1067
1077
  return names.length > 0 ? new Set(names) : undefined;
@@ -1373,7 +1383,11 @@ export async function runAgent(
1373
1383
  pendingToolTimeouts.delete(toolCallId);
1374
1384
  try {
1375
1385
  session.abort();
1376
- } catch {}
1386
+ } catch {
1387
+ // The session may already be torn down by the time this fires; the
1388
+ // timeout's only job is to stop a hung tool, and there is nothing
1389
+ // left to stop.
1390
+ }
1377
1391
  }, defaultToolTimeoutMs);
1378
1392
  handle.unref?.();
1379
1393
  pendingToolTimeouts.set(toolCallId, handle);
@@ -1528,7 +1542,11 @@ export async function resumeAgent(
1528
1542
  resumePendingTimeouts.delete(id);
1529
1543
  try {
1530
1544
  session.abort();
1531
- } catch {}
1545
+ } catch {
1546
+ // The session may already be torn down by the time this fires; the
1547
+ // timeout's only job is to stop a hung tool, and there is nothing
1548
+ // left to stop.
1549
+ }
1532
1550
  }, defaultToolTimeoutMs);
1533
1551
  handle.unref?.();
1534
1552
  resumePendingTimeouts.set(id, handle);