@peakflo/agent-harness 0.2.11 → 0.2.12-rc.1

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.
@@ -24,6 +24,9 @@ exports.AcpAdapter = exports.ACP_SESSION_START_TIMEOUT_MS = void 0;
24
24
  * - an asking permission mode puts Cursor in a read-only mode, or the turn
25
25
  * is refused; "always allow" is not offered to the person answering and
26
26
  * is not what a bypass turn answers with
27
+ * - the agent's system prompt is sent once, as a delimited block ahead of
28
+ * the first prompt of a NEW session (`./session-instructions.ts`); a
29
+ * loaded session already has it, and its echo is kept out of the transcript
27
30
  */
28
31
  const child_process_1 = require("child_process");
29
32
  const crypto_1 = require("crypto");
@@ -33,6 +36,7 @@ const util_1 = require("util");
33
36
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
34
37
  const vendor_environment_1 = require("./vendor-environment");
35
38
  const cursor_acp_1 = require("./cursor-acp");
39
+ const session_instructions_1 = require("./session-instructions");
36
40
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
37
41
  /**
38
42
  * The ACP options known to answer ONE request (`allow_once`, `reject_once`).
@@ -331,7 +335,10 @@ class AcpAdapter {
331
335
  toolCallMetadata: new Map(),
332
336
  lastError: null,
333
337
  codexUseApiKey,
334
- codexAuthSummary
338
+ codexAuthSummary,
339
+ // ACP has no system-prompt field; the first prompt carries it once.
340
+ pendingInstructions: (0, session_instructions_1.sessionInstructionsBlock)(config.systemPrompt),
341
+ instructionsInFlight: null
335
342
  };
336
343
  // Temporarily store with workspace ID, will re-key after getting ACP session ID
337
344
  this.sessions.set(sessionId, session);
@@ -487,7 +494,12 @@ class AcpAdapter {
487
494
  toolCallMetadata: new Map(),
488
495
  lastError: null,
489
496
  codexUseApiKey,
490
- codexAuthSummary
497
+ codexAuthSummary,
498
+ // A loaded session was given the instructions on its first turn and the
499
+ // agent keeps them in its own history; sending them again would repeat
500
+ // them in the conversation on every turn.
501
+ pendingInstructions: null,
502
+ instructionsInFlight: null
491
503
  };
492
504
  // Store with the Codex UUID (same as sessionId since we now return UUID from createSession)
493
505
  this.sessions.set(sessionId, session);
@@ -595,12 +607,22 @@ class AcpAdapter {
595
607
  session.currentTurnId++;
596
608
  session.activeTurnId = session.currentTurnId;
597
609
  session.lastChunkTime = null;
610
+ // The system prompt goes once, as its own block ahead of the first
611
+ // message of a new session. It is not part of the user's prompt above,
612
+ // so the transcript and the runner's echo filter see only the user's text.
613
+ const instructions = session.pendingInstructions;
614
+ session.pendingInstructions = null;
615
+ // Recorded before the write: the answer can arrive as soon as it is sent.
616
+ session.instructionsInFlight = instructions
617
+ ? { requestId: session.nextRequestId, block: instructions }
618
+ : null;
598
619
  // Send prompt via ACP (prompt must be an array of ContentBlock objects)
599
620
  // Note: session/prompt is a long-running operation that responds via session/update notifications
600
621
  // We send the request but don't await the response to avoid timeout
601
622
  this.sendRpcRequestNoWait(session, 'session/prompt', {
602
623
  sessionId: session.acpSessionId,
603
624
  prompt: [
625
+ ...(instructions ? [{ type: 'text', text: instructions }] : []),
604
626
  {
605
627
  type: 'text',
606
628
  text: promptText
@@ -899,6 +921,16 @@ class AcpAdapter {
899
921
  }
900
922
  // No pending request - could be error for permission response or other async operation
901
923
  const response = message;
924
+ // The prompt that carried the system-prompt block has been answered. A
925
+ // refused prompt never reached the agent's history, so the block goes
926
+ // again with the next prompt instead of being lost for the session.
927
+ const inFlight = session.instructionsInFlight;
928
+ if (inFlight && inFlight.requestId === message.id) {
929
+ session.instructionsInFlight = null;
930
+ if ('error' in response && response.error) {
931
+ session.pendingInstructions = inFlight.block;
932
+ }
933
+ }
902
934
  if ('error' in response && response.error) {
903
935
  // Check for quota/usage limit errors from the provider
904
936
  const errorInfo = this.extractCodexErrorInfo(response.error);
@@ -1751,27 +1783,33 @@ class AcpAdapter {
1751
1783
  const currentText = partContentLengths.get(userId) || '';
1752
1784
  const newText = this.mergeStreamingText(currentText, chunk);
1753
1785
  partContentLengths.set(userId, newText);
1754
- // Return update part with accumulated text
1755
- parts.push({
1756
- id: userId,
1757
- type: coding_agent_adapter_1.MessagePartType.TEXT,
1758
- text: newText,
1759
- role: 'user',
1760
- update: seenPartIds.has(userId)
1761
- });
1762
- seenPartIds.add(userId);
1786
+ // The echo of the system-prompt block is not the user's words.
1787
+ const visibleText = (0, session_instructions_1.stripSessionInstructions)(newText);
1788
+ if (visibleText) {
1789
+ // Return update part with accumulated text
1790
+ parts.push({
1791
+ id: userId,
1792
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
1793
+ text: visibleText,
1794
+ role: 'user',
1795
+ update: seenPartIds.has(userId)
1796
+ });
1797
+ seenPartIds.add(userId);
1798
+ }
1763
1799
  }
1764
1800
  }
1765
1801
  else if (update.sessionUpdate === 'agent_message' ||
1766
1802
  update.sessionUpdate === 'assistant_message' ||
1767
1803
  update.sessionUpdate === 'user_message' ||
1768
1804
  update.sessionUpdate === 'human_message') {
1769
- const text = this.extractTextFromUpdateContent(update.content);
1770
- if (!text)
1771
- return parts;
1772
1805
  const role = update.sessionUpdate === 'user_message' || update.sessionUpdate === 'human_message'
1773
1806
  ? 'user'
1774
1807
  : 'assistant';
1808
+ const rawText = this.extractTextFromUpdateContent(update.content);
1809
+ // The echo of the system-prompt block is not the user's words.
1810
+ const text = role === 'user' ? (0, session_instructions_1.stripSessionInstructions)(rawText) : rawText;
1811
+ if (!text)
1812
+ return parts;
1775
1813
  // For assistant messages without a stable messageId, derive a
1776
1814
  // deterministic ID from the current turn so it matches the streaming
1777
1815
  // chunk ID (`agent-response-{turnId}`). This prevents duplicates
@@ -1462,6 +1462,15 @@ class OpencodeAdapter {
1462
1462
  async executePromptWithRetry(ocClient, sessionId, parts, modelParam, config, promptAbort) {
1463
1463
  const maxRetries = OpencodeAdapter.PROMPT_MAX_RETRIES;
1464
1464
  const baseDelay = OpencodeAdapter.PROMPT_RETRY_BASE_DELAY_MS;
1465
+ /*
1466
+ * The agent's system prompt goes in OpenCode's own `system` field, which
1467
+ * the server adds to the model's system prompt for this request. It is
1468
+ * kept on the user message, not added to the conversation, so it is sent
1469
+ * with every prompt: a turn without it would run without the agent's
1470
+ * instructions. Trimmed only, so the bytes stay the same turn to turn and
1471
+ * the prompt prefix stays cacheable. An empty prompt sends no field.
1472
+ */
1473
+ const system = config.systemPrompt?.trim() || undefined;
1465
1474
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1466
1475
  if (promptAbort.signal.aborted)
1467
1476
  return;
@@ -1475,6 +1484,7 @@ class OpencodeAdapter {
1475
1484
  body: {
1476
1485
  parts: parts,
1477
1486
  ...(modelParam && { model: modelParam }),
1487
+ ...(system && { system }),
1478
1488
  ...(config.tools && { tools: config.tools })
1479
1489
  },
1480
1490
  ...(config.workspaceDir && {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The control plane's system prompt for a backend that has no system-prompt
3
+ * slot of its own.
4
+ *
5
+ * Pi, Codex and Claude Code take the agent's system prompt natively
6
+ * (`--append-system-prompt`, `developerInstructions`, the SDK's
7
+ * `systemPrompt`). ACP has no such field: `session/new` accepts only `cwd` and
8
+ * `mcpServers`, and Cursor's `cursor-agent acp` documents no `_meta` key for
9
+ * it. So an ACP session receives the prompt ONCE, as a delimited content block
10
+ * ahead of the first user message of a new session. The agent keeps its own
11
+ * conversation, so every later turn (a resumed `session/load`) already has it.
12
+ *
13
+ * The wrapper is constant and the prompt is only trimmed, so the bytes the
14
+ * model sees are the same for the same agent: the conversation prefix stays
15
+ * cacheable.
16
+ */
17
+ export declare const SESSION_INSTRUCTIONS_OPEN = "<system_instructions>";
18
+ export declare const SESSION_INSTRUCTIONS_CLOSE = "</system_instructions>";
19
+ /** The block to send ahead of the first prompt, or null when there is none. */
20
+ export declare function sessionInstructionsBlock(systemPrompt: string | null | undefined): string | null;
21
+ /**
22
+ * The user's text without a leading instructions block.
23
+ *
24
+ * An ACP agent may echo the prompt back as `user_message_chunk` updates, live
25
+ * or when a session is loaded again. The block is not the user's words and
26
+ * must not reach the transcript. Matched on the delimiters, not on the prompt
27
+ * text, so a block sent under an older version of the agent's prompt is
28
+ * removed too. While a streamed echo has not yet reached the closing tag the
29
+ * whole text is still block, and nothing is shown.
30
+ */
31
+ export declare function stripSessionInstructions(text: string): string;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * The control plane's system prompt for a backend that has no system-prompt
4
+ * slot of its own.
5
+ *
6
+ * Pi, Codex and Claude Code take the agent's system prompt natively
7
+ * (`--append-system-prompt`, `developerInstructions`, the SDK's
8
+ * `systemPrompt`). ACP has no such field: `session/new` accepts only `cwd` and
9
+ * `mcpServers`, and Cursor's `cursor-agent acp` documents no `_meta` key for
10
+ * it. So an ACP session receives the prompt ONCE, as a delimited content block
11
+ * ahead of the first user message of a new session. The agent keeps its own
12
+ * conversation, so every later turn (a resumed `session/load`) already has it.
13
+ *
14
+ * The wrapper is constant and the prompt is only trimmed, so the bytes the
15
+ * model sees are the same for the same agent: the conversation prefix stays
16
+ * cacheable.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.SESSION_INSTRUCTIONS_CLOSE = exports.SESSION_INSTRUCTIONS_OPEN = void 0;
20
+ exports.sessionInstructionsBlock = sessionInstructionsBlock;
21
+ exports.stripSessionInstructions = stripSessionInstructions;
22
+ exports.SESSION_INSTRUCTIONS_OPEN = '<system_instructions>';
23
+ exports.SESSION_INSTRUCTIONS_CLOSE = '</system_instructions>';
24
+ /** The block to send ahead of the first prompt, or null when there is none. */
25
+ function sessionInstructionsBlock(systemPrompt) {
26
+ const trimmed = systemPrompt?.trim();
27
+ if (!trimmed)
28
+ return null;
29
+ // A closing tag inside the prompt would end the block early, for the model
30
+ // and for `stripSessionInstructions`, so it is neutralised. Deterministic,
31
+ // so the bytes stay the same turn to turn.
32
+ const prompt = trimmed
33
+ .split(exports.SESSION_INSTRUCTIONS_CLOSE)
34
+ .join('</ system_instructions>');
35
+ return [
36
+ exports.SESSION_INSTRUCTIONS_OPEN,
37
+ 'These are your standing instructions from the platform for this whole session. Follow them in every turn; they are not a message from the user.',
38
+ '',
39
+ prompt,
40
+ exports.SESSION_INSTRUCTIONS_CLOSE
41
+ ].join('\n');
42
+ }
43
+ /**
44
+ * The user's text without a leading instructions block.
45
+ *
46
+ * An ACP agent may echo the prompt back as `user_message_chunk` updates, live
47
+ * or when a session is loaded again. The block is not the user's words and
48
+ * must not reach the transcript. Matched on the delimiters, not on the prompt
49
+ * text, so a block sent under an older version of the agent's prompt is
50
+ * removed too. While a streamed echo has not yet reached the closing tag the
51
+ * whole text is still block, and nothing is shown.
52
+ */
53
+ function stripSessionInstructions(text) {
54
+ const start = text.trimStart();
55
+ if (!start)
56
+ return text;
57
+ if (!start.startsWith(exports.SESSION_INSTRUCTIONS_OPEN)) {
58
+ // A streamed echo can stop part-way through the opening tag. A user
59
+ // message that is itself a prefix of the tag (`<sys`) is hidden too; at
60
+ // two characters or more of this exact tag that is accepted.
61
+ return exports.SESSION_INSTRUCTIONS_OPEN.startsWith(start) && start.length > 1
62
+ ? ''
63
+ : text;
64
+ }
65
+ const end = start.indexOf(exports.SESSION_INSTRUCTIONS_CLOSE);
66
+ if (end < 0)
67
+ return '';
68
+ return start.slice(end + exports.SESSION_INSTRUCTIONS_CLOSE.length).trimStart();
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.11",
3
+ "version": "0.2.12-rc.1",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",