@yandy0725/pi-subagents 0.1.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.
- package/README.md +155 -0
- package/README.zh.md +155 -0
- package/index.ts +1 -0
- package/package.json +49 -0
- package/src/config/agent-types.ts +127 -0
- package/src/config/custom-agents.ts +109 -0
- package/src/config/default-agents.ts +117 -0
- package/src/config/invocation-config.ts +30 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/handlers/lifecycle.ts +63 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +186 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +88 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +240 -0
- package/src/lifecycle/parent-snapshot.ts +45 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +353 -0
- package/src/lifecycle/subagent-session.ts +232 -0
- package/src/lifecycle/subagent-state.ts +216 -0
- package/src/lifecycle/subagent.ts +498 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +65 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +49 -0
- package/src/observation/notification-state.ts +27 -0
- package/src/observation/notification.ts +186 -0
- package/src/observation/record-observer.ts +75 -0
- package/src/observation/renderer.ts +63 -0
- package/src/observation/subagent-events-observer.ts +94 -0
- package/src/runtime.ts +77 -0
- package/src/service/service-adapter.ts +131 -0
- package/src/service/service.ts +123 -0
- package/src/session/content-items.ts +51 -0
- package/src/session/context.ts +78 -0
- package/src/session/conversation.ts +44 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +121 -0
- package/src/session/prompts.ts +83 -0
- package/src/session/session-config.ts +172 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +227 -0
- package/src/tools/agent-tool.ts +220 -0
- package/src/tools/background-spawner.ts +66 -0
- package/src/tools/foreground-runner.ts +114 -0
- package/src/tools/get-result-tool.ts +120 -0
- package/src/tools/helpers.ts +105 -0
- package/src/tools/result-renderer.ts +109 -0
- package/src/tools/spawn-config.ts +150 -0
- package/src/tools/steer-tool.ts +90 -0
- package/src/types.ts +115 -0
- package/src/ui/agent-widget.ts +311 -0
- package/src/ui/display.ts +174 -0
- package/src/ui/session-navigation.ts +147 -0
- package/src/ui/session-navigator.ts +406 -0
- package/src/ui/subagents-settings.ts +77 -0
- package/src/ui/widget-renderer.ts +296 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime.ts — SubagentRuntime: composition root for all mutable extension state.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates module-scope state in agent-runner.ts and closure-scoped state
|
|
5
|
+
* in index.ts by consolidating them into a single, testable object.
|
|
6
|
+
* Follows the same pattern as pi-permission-system's ExtensionRuntime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { buildParentSnapshot, type ParentSnapshot } from "./lifecycle/parent-snapshot";
|
|
10
|
+
import type { ModelInfo } from "./tools/spawn-config";
|
|
11
|
+
import type { SessionContext } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Narrow config subset read by Agent when driving the turn loop (defaultMaxTurns, graceTurns).
|
|
15
|
+
* Kept separate so callers can satisfy it without depending on the full runtime.
|
|
16
|
+
*/
|
|
17
|
+
export interface RunConfig {
|
|
18
|
+
readonly defaultMaxTurns: number | undefined;
|
|
19
|
+
readonly graceTurns: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* All mutable state owned by the pi-subagents extension.
|
|
24
|
+
*
|
|
25
|
+
* Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
|
|
26
|
+
* Tests construct a fresh runtime per test for full isolation.
|
|
27
|
+
*/
|
|
28
|
+
export class SubagentRuntime {
|
|
29
|
+
// ── Session state (was closure-scoped in index.ts) ───────────────────────
|
|
30
|
+
/** Active Pi session context — set on session_start, cleared on session_shutdown. */
|
|
31
|
+
currentCtx: SessionContext | undefined = undefined;
|
|
32
|
+
|
|
33
|
+
// ── Session-context methods ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Store the active Pi session context (called from session_start). */
|
|
36
|
+
setSessionContext(ctx: SessionContext): void {
|
|
37
|
+
this.currentCtx = ctx;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Clear the session context (called from session_shutdown). */
|
|
41
|
+
clearSessionContext(): void {
|
|
42
|
+
this.currentCtx = undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a parent snapshot from the current session context.
|
|
47
|
+
* Only valid during an active session (currentCtx is defined).
|
|
48
|
+
*/
|
|
49
|
+
buildSnapshot(inheritContext: boolean): ParentSnapshot {
|
|
50
|
+
return buildParentSnapshot(this.currentCtx!, inheritContext);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract model info from the current session context. */
|
|
54
|
+
getModelInfo(): ModelInfo {
|
|
55
|
+
return {
|
|
56
|
+
parentModel: this.currentCtx?.model as ModelInfo["parentModel"],
|
|
57
|
+
modelRegistry: this.currentCtx?.modelRegistry,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extract session identity from the current session context. */
|
|
62
|
+
getSessionInfo(): { parentSessionFile: string; parentSessionId: string } {
|
|
63
|
+
return {
|
|
64
|
+
parentSessionFile: this.currentCtx?.sessionManager.getSessionFile() ?? "",
|
|
65
|
+
parentSessionId: this.currentCtx?.sessionManager.getSessionId() ?? "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a fully-initialized SubagentRuntime with default values.
|
|
72
|
+
*
|
|
73
|
+
* Call once at extension startup; pass the result to factories and handlers.
|
|
74
|
+
*/
|
|
75
|
+
export function createSubagentRuntime(): SubagentRuntime {
|
|
76
|
+
return new SubagentRuntime();
|
|
77
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service-adapter.ts — Adapter that wraps SubagentManager to satisfy SubagentsService.
|
|
3
|
+
*
|
|
4
|
+
* Handles model resolution at the API boundary, record serialization
|
|
5
|
+
* (stripping non-serializable fields), and session gating.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
9
|
+
import type { WorkspaceProvider } from "../lifecycle/workspace";
|
|
10
|
+
import type { SpawnOptions, SubagentRecord, SubagentsService } from "../service/service";
|
|
11
|
+
import type { ModelRegistry } from "../session/model-resolver";
|
|
12
|
+
import type { SessionContext, Subagent } from "../types";
|
|
13
|
+
|
|
14
|
+
/** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
|
|
15
|
+
export interface SubagentManagerLike {
|
|
16
|
+
spawn(snapshot: ParentSnapshot, type: string, prompt: string, options: unknown): string;
|
|
17
|
+
getRecord(id: string): Subagent | undefined;
|
|
18
|
+
listAgents(): Subagent[];
|
|
19
|
+
abort(id: string): boolean;
|
|
20
|
+
waitForAll(): Promise<void>;
|
|
21
|
+
hasRunning(): boolean;
|
|
22
|
+
registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Narrow runtime interface consumed by the service adapter.
|
|
27
|
+
* `SubagentRuntime` satisfies this structurally; tests use plain stubs.
|
|
28
|
+
*/
|
|
29
|
+
export interface ServiceRuntimeLike {
|
|
30
|
+
readonly currentCtx: SessionContext | undefined;
|
|
31
|
+
buildSnapshot(inheritContext: boolean): ParentSnapshot;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Adapter that wraps SubagentManager to satisfy SubagentsService. */
|
|
35
|
+
export class SubagentsServiceAdapter implements SubagentsService {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly manager: SubagentManagerLike,
|
|
38
|
+
private readonly resolveModel: (input: string, registry: ModelRegistry) => unknown,
|
|
39
|
+
private readonly runtime: ServiceRuntimeLike,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
spawn(type: string, prompt: string, options?: SpawnOptions): string {
|
|
43
|
+
if (!this.runtime.currentCtx) {
|
|
44
|
+
throw new Error("No active session — cannot spawn agents outside a session.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let model: unknown;
|
|
48
|
+
if (options?.model) {
|
|
49
|
+
const registry = this.runtime.currentCtx.modelRegistry;
|
|
50
|
+
if (!registry) {
|
|
51
|
+
throw new Error("No model registry available.");
|
|
52
|
+
}
|
|
53
|
+
const resolved = this.resolveModel(options.model, registry);
|
|
54
|
+
if (typeof resolved === "string") {
|
|
55
|
+
throw new Error(resolved);
|
|
56
|
+
}
|
|
57
|
+
model = resolved;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const description = options?.description ?? prompt.slice(0, 80);
|
|
61
|
+
const isBackground = !(options?.foreground ?? false);
|
|
62
|
+
|
|
63
|
+
const snapshot = this.runtime.buildSnapshot(options?.inheritContext ?? false);
|
|
64
|
+
return this.manager.spawn(snapshot, type, prompt, {
|
|
65
|
+
description,
|
|
66
|
+
model,
|
|
67
|
+
maxTurns: options?.maxTurns,
|
|
68
|
+
thinkingLevel: options?.thinkingLevel,
|
|
69
|
+
inheritContext: options?.inheritContext,
|
|
70
|
+
bypassQueue: options?.bypassQueue,
|
|
71
|
+
isBackground,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getRecord(id: string): SubagentRecord | undefined {
|
|
76
|
+
const record = this.manager.getRecord(id);
|
|
77
|
+
return record ? toSubagentRecord(record) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
listAgents(): SubagentRecord[] {
|
|
81
|
+
return this.manager.listAgents().map(toSubagentRecord);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
abort(id: string): boolean {
|
|
85
|
+
return this.manager.abort(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async steer(id: string, message: string): Promise<boolean> {
|
|
89
|
+
const record = this.manager.getRecord(id);
|
|
90
|
+
if (record?.status !== "running") {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
await record.steer(message);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async waitForAll(): Promise<void> {
|
|
98
|
+
return this.manager.waitForAll();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
hasRunning(): boolean {
|
|
102
|
+
return this.manager.hasRunning();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
|
|
106
|
+
return this.manager.registerWorkspaceProvider(provider);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert an internal Subagent to a serializable SubagentRecord.
|
|
112
|
+
* Uses an explicit allowlist — new fields must be opted in.
|
|
113
|
+
*/
|
|
114
|
+
export function toSubagentRecord(record: Subagent): SubagentRecord {
|
|
115
|
+
const out: SubagentRecord = {
|
|
116
|
+
id: record.id,
|
|
117
|
+
type: record.type,
|
|
118
|
+
description: record.description,
|
|
119
|
+
status: record.status,
|
|
120
|
+
toolUses: record.toolUses,
|
|
121
|
+
startedAt: record.startedAt,
|
|
122
|
+
lifetimeUsage: record.lifetimeUsage,
|
|
123
|
+
compactionCount: record.compactionCount,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (record.result !== undefined) out.result = record.result;
|
|
127
|
+
if (record.error !== undefined) out.error = record.error;
|
|
128
|
+
if (record.completedAt !== undefined) out.completedAt = record.completedAt;
|
|
129
|
+
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service.ts — Public API surface for cross-extension access to subagents.
|
|
3
|
+
*
|
|
4
|
+
* Consumers declare this package as an optional peer dependency and use
|
|
5
|
+
* dynamic import to access the accessor functions:
|
|
6
|
+
*
|
|
7
|
+
* const { getSubagentsService } = await import("@yandy0725/pi-subagents");
|
|
8
|
+
* const svc = getSubagentsService();
|
|
9
|
+
* svc?.spawn("Explore", "Check for stale TODOs");
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SubagentStatus } from "../lifecycle/subagent";
|
|
13
|
+
import type { LifetimeUsage } from "../lifecycle/usage";
|
|
14
|
+
import type {
|
|
15
|
+
Workspace,
|
|
16
|
+
WorkspaceDisposeOutcome,
|
|
17
|
+
WorkspaceDisposeResult,
|
|
18
|
+
WorkspacePrepareContext,
|
|
19
|
+
WorkspaceProvider,
|
|
20
|
+
} from "../lifecycle/workspace";
|
|
21
|
+
|
|
22
|
+
// SubagentStatus is defined in the lifecycle layer (single home) and re-exported
|
|
23
|
+
// here for the public API surface — mirrors the LifetimeUsage / workspace pattern.
|
|
24
|
+
export type { SubagentStatus } from "../lifecycle/subagent";
|
|
25
|
+
// Generative extension seam (ADR 0002, Phase 16 Step 2). The provider type
|
|
26
|
+
// and all four collaborator types it references are re-exported by name so
|
|
27
|
+
// consumers can import them directly rather than recovering them via
|
|
28
|
+
// indexed-access inference (e.g. `Parameters<WorkspaceProvider["prepare"]>[0]`).
|
|
29
|
+
export type {
|
|
30
|
+
LifetimeUsage,
|
|
31
|
+
Workspace,
|
|
32
|
+
WorkspaceDisposeOutcome,
|
|
33
|
+
WorkspaceDisposeResult,
|
|
34
|
+
WorkspacePrepareContext,
|
|
35
|
+
WorkspaceProvider,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Serializable snapshot of an agent's state — no live session objects. */
|
|
39
|
+
export interface SubagentRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
type: string;
|
|
42
|
+
description: string;
|
|
43
|
+
status: SubagentStatus;
|
|
44
|
+
result?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
toolUses: number;
|
|
47
|
+
startedAt: number;
|
|
48
|
+
completedAt?: number;
|
|
49
|
+
lifetimeUsage: LifetimeUsage;
|
|
50
|
+
compactionCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Options for spawning an agent via the service. */
|
|
54
|
+
export interface SpawnOptions {
|
|
55
|
+
description?: string;
|
|
56
|
+
model?: string;
|
|
57
|
+
maxTurns?: number;
|
|
58
|
+
thinkingLevel?: string;
|
|
59
|
+
inheritContext?: boolean;
|
|
60
|
+
foreground?: boolean;
|
|
61
|
+
bypassQueue?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The public service contract for cross-extension subagent access. */
|
|
65
|
+
export interface SubagentsService {
|
|
66
|
+
/** Spawn an agent. Returns the agent ID immediately. */
|
|
67
|
+
spawn(type: string, prompt: string, options?: SpawnOptions): string;
|
|
68
|
+
|
|
69
|
+
/** Get a snapshot of an agent's current state. */
|
|
70
|
+
getRecord(id: string): SubagentRecord | undefined;
|
|
71
|
+
|
|
72
|
+
/** List all tracked agents, most recent first. */
|
|
73
|
+
listAgents(): SubagentRecord[];
|
|
74
|
+
|
|
75
|
+
/** Abort a running or queued agent. Returns false if not found. */
|
|
76
|
+
abort(id: string): boolean;
|
|
77
|
+
|
|
78
|
+
/** Send a steering message to a running agent. */
|
|
79
|
+
steer(id: string, message: string): Promise<boolean>;
|
|
80
|
+
|
|
81
|
+
/** Wait for all running and queued agents to complete. */
|
|
82
|
+
waitForAll(): Promise<void>;
|
|
83
|
+
|
|
84
|
+
/** Whether any agents are running or queued. */
|
|
85
|
+
hasRunning(): boolean;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register the single workspace provider that supplies a child's working
|
|
89
|
+
* directory plus bracketed setup/teardown. Throws if one is already
|
|
90
|
+
* registered. Returns a disposer that unregisters the provider.
|
|
91
|
+
*/
|
|
92
|
+
registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Event channel constants for pi.events subscriptions. */
|
|
96
|
+
export const SUBAGENT_EVENTS = {
|
|
97
|
+
STARTED: "subagents:started",
|
|
98
|
+
COMPLETED: "subagents:completed",
|
|
99
|
+
FAILED: "subagents:failed",
|
|
100
|
+
COMPACTED: "subagents:compacted",
|
|
101
|
+
CREATED: "subagents:created",
|
|
102
|
+
STEERED: "subagents:steered",
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
// ---- Accessor functions ----
|
|
106
|
+
|
|
107
|
+
const SERVICE_KEY = Symbol.for("@yandy0725/pi-subagents:service");
|
|
108
|
+
|
|
109
|
+
/** Publish the SubagentsService on globalThis for cross-extension access. */
|
|
110
|
+
export function publishSubagentsService(service: SubagentsService): void {
|
|
111
|
+
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Retrieve the published SubagentsService, or undefined if not yet published. */
|
|
115
|
+
export function getSubagentsService(): SubagentsService | undefined {
|
|
116
|
+
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as SubagentsService | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Remove the SubagentsService from globalThis (call on shutdown/reload). */
|
|
120
|
+
export function unpublishSubagentsService(): void {
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
122
|
+
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
123
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-items.ts — Shared parsing utilities for Pi SDK message content items.
|
|
3
|
+
*
|
|
4
|
+
* Provides type-safe extraction of text parts and tool-call names from
|
|
5
|
+
* assistant message content arrays. Pure functions — no IO.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
|
|
9
|
+
|
|
10
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Extracted text parts and tool names from assistant message content. */
|
|
13
|
+
export interface AssistantContentParts {
|
|
14
|
+
textParts: string[];
|
|
15
|
+
toolNames: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Functions ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts the display name from a tool-call content item.
|
|
22
|
+
*
|
|
23
|
+
* Returns 'unknown' for non-toolCall items.
|
|
24
|
+
* The Pi SDK's ToolCall.name is always present — no fallback chain needed.
|
|
25
|
+
*/
|
|
26
|
+
export function getToolCallName(c: { type: string }): string {
|
|
27
|
+
if (c.type !== "toolCall") return "unknown";
|
|
28
|
+
return (c as ToolCall).name;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract text parts and tool-call names from assistant message content items.
|
|
33
|
+
*
|
|
34
|
+
* Accepts any array whose elements carry a `type` discriminant — all Pi SDK
|
|
35
|
+
* content types (TextContent, ThinkingContent, ToolCall) satisfy this constraint.
|
|
36
|
+
* Pure data extraction — consumers apply their own presentation formatting.
|
|
37
|
+
* Skips items of unknown types (e.g. thinking blocks, images) and empty text.
|
|
38
|
+
*/
|
|
39
|
+
export function extractAssistantContent(content: ReadonlyArray<{ type: string }>): AssistantContentParts {
|
|
40
|
+
const textParts: string[] = [];
|
|
41
|
+
const toolNames: string[] = [];
|
|
42
|
+
for (const c of content) {
|
|
43
|
+
if (c.type === "text") {
|
|
44
|
+
const text = (c as TextContent).text;
|
|
45
|
+
if (text) textParts.push(text);
|
|
46
|
+
} else if (c.type === "toolCall") {
|
|
47
|
+
toolNames.push(getToolCallName(c));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { textParts, toolNames };
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context.ts — Extract parent conversation context for subagent inheritance.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TextContent } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { SessionContext } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal structural types for session branch entries consumed by buildParentContext.
|
|
10
|
+
* `getBranch()` returns `unknown[]` in SessionContext (ISP), so we cast to these
|
|
11
|
+
* local shapes instead of coupling to the SDK's SessionEntry type.
|
|
12
|
+
*/
|
|
13
|
+
type MessageEntry = {
|
|
14
|
+
type: "message";
|
|
15
|
+
message: { role: string; content: string | { type: string }[] };
|
|
16
|
+
};
|
|
17
|
+
type CompactionEntry = { type: "compaction"; summary?: string };
|
|
18
|
+
type BranchEntry = MessageEntry | CompactionEntry | { type: string };
|
|
19
|
+
|
|
20
|
+
/** Type predicate: narrow an unknown content block to TextContent. */
|
|
21
|
+
function isTextContent(c: unknown): c is TextContent {
|
|
22
|
+
return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Extract text from a message content block array. */
|
|
26
|
+
export function extractText(content: unknown[]): string {
|
|
27
|
+
return content
|
|
28
|
+
.filter(isTextContent)
|
|
29
|
+
.map((c) => c.text)
|
|
30
|
+
.join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format a message entry (user/assistant); returns undefined for roles to skip. */
|
|
34
|
+
function formatMessageEntry(entry: MessageEntry): string | undefined {
|
|
35
|
+
const msg = entry.message;
|
|
36
|
+
const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
37
|
+
if (!text.trim()) return undefined;
|
|
38
|
+
if (msg.role === "user") return `[User]: ${text.trim()}`;
|
|
39
|
+
if (msg.role === "assistant") return `[Assistant]: ${text.trim()}`;
|
|
40
|
+
return undefined; // skip toolResult and other roles
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format a compaction entry; returns undefined when no summary is present. */
|
|
44
|
+
function formatCompactionEntry(entry: CompactionEntry): string | undefined {
|
|
45
|
+
return entry.summary ? `[Summary]: ${entry.summary}` : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Dispatch a branch entry to the appropriate formatter. */
|
|
49
|
+
function formatBranchEntry(entry: BranchEntry): string | undefined {
|
|
50
|
+
if (entry.type === "message") return formatMessageEntry(entry as MessageEntry);
|
|
51
|
+
if (entry.type === "compaction") return formatCompactionEntry(entry as CompactionEntry);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a text representation of the parent conversation context.
|
|
57
|
+
* Used when inherit_context is true to give the subagent visibility
|
|
58
|
+
* into what has been discussed/done so far.
|
|
59
|
+
*/
|
|
60
|
+
export function buildParentContext(ctx: SessionContext): string {
|
|
61
|
+
const entries = ctx.sessionManager.getBranch();
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getBranch() may return undefined at runtime despite its type
|
|
63
|
+
if (!entries || entries.length === 0) return "";
|
|
64
|
+
|
|
65
|
+
const parts = (entries as BranchEntry[]).map(formatBranchEntry).filter((p): p is string => p !== undefined);
|
|
66
|
+
|
|
67
|
+
if (parts.length === 0) return "";
|
|
68
|
+
|
|
69
|
+
return `# Parent Conversation Context
|
|
70
|
+
The following is the conversation history from the parent session that spawned you.
|
|
71
|
+
Use this context to understand what has been discussed and decided so far.
|
|
72
|
+
|
|
73
|
+
${parts.join("\n\n")}
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
# Your Task (below)
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation.ts — Render a subagent session's messages as formatted text.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-runner.ts (issue #265) into the session domain, where the
|
|
5
|
+
* other message-extraction helpers (content-items, context) live. Consumed by
|
|
6
|
+
* the get_subagent_result tool's verbose output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { extractAssistantContent } from "../session/content-items";
|
|
11
|
+
import { extractText } from "../session/context";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the subagent's conversation messages as formatted text.
|
|
15
|
+
*/
|
|
16
|
+
export function getAgentConversation(session: AgentSession): string {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const msg of session.messages) {
|
|
20
|
+
if (msg.role === "user") {
|
|
21
|
+
const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
22
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
23
|
+
} else if (msg.role === "assistant") {
|
|
24
|
+
const { textParts, toolNames } = extractAssistantContent(msg.content);
|
|
25
|
+
const attribution = formatAttribution(msg);
|
|
26
|
+
if (textParts.length > 0) parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
|
|
27
|
+
if (toolNames.length > 0) parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
|
|
28
|
+
} else if (msg.role === "toolResult") {
|
|
29
|
+
const text = extractText(msg.content);
|
|
30
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
31
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parts.join("\n\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a `(provider/model)` attribution suffix for assistant messages. */
|
|
39
|
+
function formatAttribution(msg: { provider?: string; model?: string }): string {
|
|
40
|
+
const { provider, model } = msg;
|
|
41
|
+
if (!provider && !model) return "";
|
|
42
|
+
if (provider && model) return ` (${provider}/${model})`;
|
|
43
|
+
return ` (${provider ?? model})`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env.ts — Detect environment info (git, platform) for subagent system prompts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { debugLog } from "../debug";
|
|
6
|
+
import type { ShellExec } from "../types";
|
|
7
|
+
|
|
8
|
+
export interface EnvInfo {
|
|
9
|
+
isGitRepo: boolean;
|
|
10
|
+
branch: string;
|
|
11
|
+
platform: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function detectEnv(exec: ShellExec, cwd: string): Promise<EnvInfo> {
|
|
15
|
+
let isGitRepo = false;
|
|
16
|
+
let branch = "";
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
|
|
20
|
+
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
|
21
|
+
} catch (err) {
|
|
22
|
+
debugLog("git rev-parse", err);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isGitRepo) {
|
|
26
|
+
try {
|
|
27
|
+
const result = await exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
|
|
28
|
+
branch = result.code === 0 ? result.stdout.trim() : "unknown";
|
|
29
|
+
} catch (err) {
|
|
30
|
+
debugLog("git branch", err);
|
|
31
|
+
branch = "unknown";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
isGitRepo,
|
|
37
|
+
branch,
|
|
38
|
+
platform: process.platform,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-redundant-type-constituents -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
|
+
/**
|
|
3
|
+
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ModelEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ModelRegistry {
|
|
13
|
+
find(provider: string, modelId: string): any;
|
|
14
|
+
getAll(): any[];
|
|
15
|
+
getAvailable?(): any[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Successful model resolution — `model` is the resolved or inherited model instance. */
|
|
19
|
+
export interface ModelResolutionResult {
|
|
20
|
+
model: any;
|
|
21
|
+
error?: undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Failed model resolution when the model was user-specified (params) — surface the error. */
|
|
25
|
+
export interface ModelResolutionError {
|
|
26
|
+
model?: undefined;
|
|
27
|
+
error: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Discriminated union returned by `resolveInvocationModel`. */
|
|
31
|
+
export type ModelResolution = ModelResolutionResult | ModelResolutionError;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the effective model for an agent invocation.
|
|
35
|
+
*
|
|
36
|
+
* Encapsulates the three-branch fallback policy used in `Agent.execute`:
|
|
37
|
+
* 1. No `modelInput` → inherit `parentModel`.
|
|
38
|
+
* 2. `modelInput` resolves → return the resolved model.
|
|
39
|
+
* 3. `modelInput` fails:
|
|
40
|
+
* - `modelFromParams` true → return `{ error }` so the caller can surface it.
|
|
41
|
+
* - `modelFromParams` false → silent fallback to `parentModel`.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveInvocationModel(
|
|
44
|
+
parentModel: unknown,
|
|
45
|
+
modelInput: string | undefined,
|
|
46
|
+
modelFromParams: boolean,
|
|
47
|
+
registry: ModelRegistry,
|
|
48
|
+
): ModelResolution {
|
|
49
|
+
if (!modelInput) return { model: parentModel };
|
|
50
|
+
const resolved = resolveModel(modelInput, registry);
|
|
51
|
+
if (typeof resolved !== "string") return { model: resolved };
|
|
52
|
+
if (modelFromParams) return { error: resolved };
|
|
53
|
+
return { model: parentModel };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a model string to a Model instance.
|
|
58
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
59
|
+
* Returns the Model on success, or an error message string on failure.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveModel(input: string, registry: ModelRegistry): any | string {
|
|
62
|
+
// Available models (those with auth configured)
|
|
63
|
+
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
|
64
|
+
const availableSet = new Set(all.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
|
|
65
|
+
|
|
66
|
+
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
|
67
|
+
const slashIdx = input.indexOf("/");
|
|
68
|
+
if (slashIdx !== -1) {
|
|
69
|
+
const provider = input.slice(0, slashIdx);
|
|
70
|
+
const modelId = input.slice(slashIdx + 1);
|
|
71
|
+
if (availableSet.has(input.toLowerCase())) {
|
|
72
|
+
const found = registry.find(provider, modelId);
|
|
73
|
+
if (found) return found;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Fuzzy match against available models
|
|
78
|
+
const query = input.toLowerCase();
|
|
79
|
+
|
|
80
|
+
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
|
81
|
+
let bestMatch: ModelEntry | undefined;
|
|
82
|
+
let bestScore = 0;
|
|
83
|
+
|
|
84
|
+
for (const m of all) {
|
|
85
|
+
const id = m.id.toLowerCase();
|
|
86
|
+
const name = m.name.toLowerCase();
|
|
87
|
+
const full = `${m.provider}/${m.id}`.toLowerCase();
|
|
88
|
+
|
|
89
|
+
let score = 0;
|
|
90
|
+
if (id === query || full === query) {
|
|
91
|
+
score = 100; // exact
|
|
92
|
+
} else if (id.includes(query) || full.includes(query)) {
|
|
93
|
+
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
|
94
|
+
} else if (name.includes(query)) {
|
|
95
|
+
score = 40 + (query.length / name.length) * 20;
|
|
96
|
+
} else if (
|
|
97
|
+
query
|
|
98
|
+
.split(/[\s\-/]+/)
|
|
99
|
+
.every((part) => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
|
|
100
|
+
) {
|
|
101
|
+
score = 20; // all parts present somewhere
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (score > bestScore) {
|
|
105
|
+
bestScore = score;
|
|
106
|
+
bestMatch = m;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (bestMatch && bestScore >= 20) {
|
|
111
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
112
|
+
if (found) return found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. No match — list available models
|
|
116
|
+
const modelList = all
|
|
117
|
+
.map((m) => ` ${m.provider}/${m.id}`)
|
|
118
|
+
.sort()
|
|
119
|
+
.join("\n");
|
|
120
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
121
|
+
}
|