pi-crew 0.9.12 → 0.9.13

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.
@@ -99,6 +99,9 @@ export interface HandoffSummary {
99
99
 
100
100
  // Context snapshot
101
101
  contextSnapshot: string;
102
+
103
+ /** Worker output text propagated through the chain (read from resultArtifact). */
104
+ outputText?: string;
102
105
  }
103
106
 
104
107
  /**
@@ -121,6 +124,9 @@ export interface TaskResult {
121
124
  filesDeleted?: string[];
122
125
  decisions?: Decision[];
123
126
  error?: string;
127
+
128
+ /** Worker's textual output (read from resultArtifact during chain execution). */
129
+ outputText?: string;
124
130
  }
125
131
 
126
132
  /**
@@ -443,6 +449,7 @@ export class HandoffManager {
443
449
  },
444
450
 
445
451
  contextSnapshot,
452
+ ...(result.outputText ? { outputText: result.outputText } : {}),
446
453
  };
447
454
  }
448
455
 
@@ -4,8 +4,13 @@ import type { AgentConfig } from "../agents/agent-config.ts";
4
4
  import type { CrewRuntimeConfig } from "../config/config.ts";
5
5
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
6
6
  import { appendEvent } from "../state/event-log.ts";
7
- import { buildMemoryBlock } from "./agent-memory.ts";
8
7
  import { trackTaskUsage } from "./usage-tracker.ts";
8
+ // NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
9
+ // block is injected via renderTaskPrompt().full (the USER prompt), which is
10
+ // shared by both the child-pi path (no system prompt) and the live-session
11
+ // path. Adding it to liveSystemPrompt() too duplicated the entire memory
12
+ // block (up to 200 lines) in both the user and system prompts. Keep memory
13
+ // in a single place: the shared user prompt. See G3 fix.
9
14
  import { createStreamingOutput, type StreamingOutputHandle } from "./streaming-output.ts";
10
15
  import { registerLiveAgent, disposeLiveAgentSession, terminateLiveAgent, updateLiveAgentStatus, trackLiveAgentToolStart, trackLiveAgentToolEnd, trackLiveAgentTurnEnd, trackLiveAgentResponseText, markLiveAgentCompleted } from "./live-agent-manager.ts";
11
16
  import { applyLiveAgentControlRequest, applyLiveAgentControlRequests, type LiveAgentControlCursor } from "./live-agent-control.ts";
@@ -371,8 +376,10 @@ function compressSessionToolDescriptions(session: LiveSessionLike): void {
371
376
  // is loaded and tree-shakeable, so adding the actual logic later is trivial.
372
377
  }
373
378
 
374
- function liveSystemPrompt(input: LiveSessionSpawnInput): string {
375
- const memory = input.agent.memory ? buildMemoryBlock(input.agent.name, input.agent.memory, input.task.cwd, Boolean(input.agent.tools?.some((tool) => tool === "write" || tool === "edit"))) : "";
379
+ export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
380
+ // Agent MEMORY is intentionally omitted here it is already injected via
381
+ // renderTaskPrompt().full (the user prompt, shared with the child-pi path).
382
+ // See the import-block note above and the G3 fix.
376
383
  const role = input.task.role;
377
384
  const styleBlock = buildCommunicationStyle(role);
378
385
  const contractBlock = buildOutputContract(role);
@@ -390,7 +397,6 @@ function liveSystemPrompt(input: LiveSessionSpawnInput): string {
390
397
  sensitiveConstraint,
391
398
  "",
392
399
  input.agent.systemPrompt || "Follow the user task exactly and report verification evidence.",
393
- memory ? `\n${memory}` : "",
394
400
  ].filter(Boolean).join("\n");
395
401
  }
396
402
 
@@ -75,7 +75,11 @@ function textFromContent(content: unknown): string[] {
75
75
  return text;
76
76
  }
77
77
 
78
- function extractText(value: unknown): string[] {
78
+ /** Extract assistant-text fragments from a parsed Pi JSON event.
79
+ * Exported so {@link ChildPiLineObserver} can capture the RAW (uncapped)
80
+ * assistant text for the authoritative result, mirroring the extraction
81
+ * order this function uses inside {@link parsePiJsonOutput}. */
82
+ export function extractText(value: unknown): string[] {
79
83
  const obj = asRecord(value);
80
84
  if (!obj) return [];
81
85
  const message = asRecord(obj.message);
@@ -14,6 +14,12 @@ export interface DependencyContextEntry {
14
14
  status: string;
15
15
  resultSummary: string;
16
16
  resultPath?: string;
17
+ /** Absolute path to the FULL (untruncated) result, teed when the inline
18
+ * resultSummary was materially truncated (>TEE_THRESHOLD_MULTIPLIER). The
19
+ * downstream worker can `read` this to recover the dropped middle. Mirrors
20
+ * the sharedReads recovery path so dependency injection is no longer
21
+ * circular (re-reading resultPath used to yield the same truncated text). */
22
+ fullOutputPath?: string;
17
23
  structuredResults?: Record<string, unknown>;
18
24
  artifactsProduced?: string[];
19
25
  usage?: { inputTokens: number; outputTokens: number; durationMs: number };
@@ -50,6 +56,16 @@ function containedExists(filePath: string, baseDir?: string): boolean {
50
56
  */
51
57
  export const MAX_RESULT_INLINE_BYTES = 32_000;
52
58
 
59
+ /**
60
+ * Tee-recovery multiplier (R2). A shared artifact is teed to disk — so the
61
+ * downstream worker can `read` the dropped middle — when its size exceeds
62
+ * this fraction of {@link MAX_RESULT_INLINE_BYTES}. Lowered from 2.0
63
+ * (64 KB) to 1.25 (40 KB) so the 32–64 KB band, where the head+tail split
64
+ * is already materially lossy for structured content, also gets a recovery
65
+ * path instead of losing the middle forever.
66
+ */
67
+ export const TEE_THRESHOLD_MULTIPLIER = 1.25;
68
+
53
69
  /**
54
70
  * Read a file and return its content, truncating to a head+tail slice if it
55
71
  * exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
@@ -110,10 +126,11 @@ function writeTeeFile(fullOutputPath: string, content: string): boolean {
110
126
  * content AND (when tee was actually written) the absolute path to the full
111
127
  * file. Returns undefined if the file cannot be read at all.
112
128
  *
113
- * Tee threshold: only when content.length > 2 * MAX_RESULT_INLINE_BYTES
114
- * (the head+tail is materially lossy small over-threshold files are not
115
- * teed because the inline content is mostly intact and the worker can live
116
- * with the 75/25 split). File content is read once and reused for both the
129
+ * Tee threshold: only when content.length > TEE_THRESHOLD_MULTIPLIER ×
130
+ * MAX_RESULT_INLINE_BYTES (R2: 1.25× = 40 KB; previously = 64 KB). Below
131
+ * the tee threshold the head+tail split is mostly intact and the worker can
132
+ * live with it; at/above the threshold the dropped middle is recoverable via
133
+ * the teed full file. File content is read once and reused for both the
117
134
  * pipeline (truncation) and the tee write (full file).
118
135
  *
119
136
  * Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
@@ -131,8 +148,10 @@ export function readIfSmallWithTee(
131
148
  const content = fs.readFileSync(safePath, "utf-8");
132
149
  if (content.length > maxChars) {
133
150
  let fullOutputPath: string | undefined;
134
- // Tee only when truncation is materially lossy (>2× threshold).
135
- if (opts.tee && content.length > maxChars * 2) {
151
+ // Tee when truncation is materially lossy (>TEE_THRESHOLD_MULTIPLIER ×
152
+ // threshold). R2: lowered from (64 KB) to 1.25× (40 KB) so the
153
+ // 32–64 KB band also gets a recovery path.
154
+ if (opts.tee && content.length > maxChars * TEE_THRESHOLD_MULTIPLIER) {
136
155
  if (writeTeeFile(opts.tee.fullOutputPath, content)) {
137
156
  fullOutputPath = opts.tee.fullOutputPath;
138
157
  }
@@ -267,13 +286,18 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
267
286
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
268
287
  const byId = new Map(tasks.map((item) => [item.id, item]));
269
288
  const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
270
- const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
289
+ const fullOutputPath = item.resultArtifact ? teePathForArtifact(manifest.artifactsRoot, task.id, item.id) : undefined;
290
+ const teeResult = item.resultArtifact
291
+ ? readIfSmallWithTee(item.resultArtifact.path, { baseDir: manifest.artifactsRoot, ...(fullOutputPath ? { tee: { fullOutputPath } } : {}) })
292
+ : undefined;
293
+ const resultText = teeResult?.content;
271
294
  return {
272
295
  taskId: item.id,
273
296
  role: item.role,
274
297
  status: item.status,
275
298
  resultSummary: resultText ?? "",
276
299
  resultPath: item.resultArtifact?.path,
300
+ ...(teeResult?.fullOutputPath ? { fullOutputPath: teeResult.fullOutputPath } : {}),
277
301
  structuredResults: resultText ? tryParseJson(resultText) : undefined,
278
302
  artifactsProduced: listTaskArtifacts(manifest, item.id),
279
303
  usage: aggregateUsage(item),
@@ -317,6 +341,11 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
317
341
  parts.push("# Dependency Outputs", "");
318
342
  for (const dep of context.dependencies) {
319
343
  parts.push(`## ${dep.taskId} (${dep.role})`, `Status: ${dep.status}`, dep.resultPath ? `Result artifact: ${dep.resultPath}` : "", "", dep.resultSummary?.trim() || "(no result output)", "");
344
+ // P1-A dependency tee-recovery hint: when the dependency's result was
345
+ // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
346
+ // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
347
+ // downstream worker can read the dropped middle instead of re-deriving.
348
+ if (dep.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
320
349
  if (dep.structuredResults) parts.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
321
350
  if (dep.artifactsProduced?.length) parts.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
322
351
  if (dep.usage) parts.push(`Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`, "");
@@ -120,7 +120,11 @@ export async function renderTaskPrompt(manifest: TeamRunManifest, step: Workflow
120
120
  // O4: project knowledge (.crew/knowledge.md) — workers don't load the
121
121
  // pi-crew extension (spawned with --no-extensions), so before_agent_start
122
122
  // never fires for them. Inject here so every worker sees project knowledge.
123
- buildKnowledgeFragment(task.cwd),
123
+ buildKnowledgeFragment(task.cwd, {
124
+ goal: manifest.goal,
125
+ taskText: step.task,
126
+ role: step.role,
127
+ }),
124
128
  ].filter(Boolean).join("\n");
125
129
 
126
130
  // Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
@@ -342,6 +342,7 @@ export async function runTeamTask(
342
342
  let error: string | undefined;
343
343
  let modelAttempts: ModelAttemptSummary[] | undefined;
344
344
  let parsedOutput: ParsedPiJsonOutput | undefined;
345
+ let rawFinalText: string | undefined;
345
346
  let finalStdout = "";
346
347
  let transcriptPath: string | undefined;
347
348
  let terminalEvidence: OperationTerminalEvidence[] = [];
@@ -717,6 +718,7 @@ export async function runTeamTask(
717
718
  childResult.stdout,
718
719
  );
719
720
  parsedOutput = parsePiJsonOutput(transcriptText);
721
+ rawFinalText = childResult.rawFinalText;
720
722
  error =
721
723
  childResult.error ||
722
724
  (childResult.exitCode && childResult.exitCode !== 0
@@ -836,6 +838,11 @@ export async function runTeamTask(
836
838
  kind: "result",
837
839
  relativePath: `results/${task.id}.txt`,
838
840
  content:
841
+ // Prefer the RAW (uncapped) final assistant text captured before the
842
+ // transcript's 16K compaction — this is the authoritative worker output.
843
+ // Fall back to transcript-derived finalText, then stdout/stderr, so a
844
+ // missing raw capture (mock/error path) never yields empty/garbage.
845
+ cleanResultText(rawFinalText) ??
839
846
  cleanResultText(parsedOutput?.finalText) ??
840
847
  cleanResultText(finalStdout) ??
841
848
  cleanResultText(finalStderr) ??
@@ -114,6 +114,12 @@ export const TeamToolParams = Type.Object({
114
114
  goal: Type.Optional(
115
115
  Type.String({ description: "High-level objective for a team run." }),
116
116
  ),
117
+ chain: Type.Optional(
118
+ Type.String({
119
+ description:
120
+ 'Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential team run, passing handoff context forward via the goal text. Supports inline goals ("...") and @team references. e.g. chain=\'"Research X" -> "Analyze" -> "Write report"\'.',
121
+ }),
122
+ ),
117
123
  task: Type.Optional(
118
124
  Type.String({
119
125
  description: "Concrete task text for direct role/agent execution.",
@@ -370,6 +376,9 @@ export interface TeamToolParamsValue {
370
376
  role?: string;
371
377
  agent?: string;
372
378
  goal?: string;
379
+ /** Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential
380
+ * team run with handoff context passed forward via the goal. */
381
+ chain?: string;
373
382
  task?: string;
374
383
  singleAgent?: boolean;
375
384
  runId?: string;