@peakflo/agent-harness 0.2.11 → 0.2.12
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
|
-
//
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.12",
|
|
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",
|