@peakflo/agent-harness 0.2.4 → 0.2.5-rc.2
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/dist/adapters/coding-agent-adapter.d.ts +6 -0
- package/dist/adapters/pi-adapter.d.ts +3 -0
- package/dist/adapters/pi-adapter.js +137 -4
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.js +27 -2
- package/dist/contract.d.ts +4 -1
- package/dist/contract.js +4 -1
- package/dist/control-plane.d.ts +6 -0
- package/dist/control-plane.js +29 -0
- package/dist/harness.d.ts +14 -4
- package/dist/harness.js +156 -22
- package/dist/runtime/cold-start-steps.d.ts +11 -0
- package/dist/runtime/cold-start-steps.js +57 -0
- package/dist/runtime/session-runner.d.ts +23 -1
- package/dist/runtime/session-runner.js +241 -12
- package/dist/types.d.ts +9 -1
- package/package.json +1 -1
|
@@ -31,11 +31,14 @@ const node_crypto_1 = require("node:crypto");
|
|
|
31
31
|
const model_gateway_1 = require("../adapters/model-gateway");
|
|
32
32
|
const contract_1 = require("../contract");
|
|
33
33
|
const attachments_1 = require("./attachments");
|
|
34
|
+
const cold_start_steps_1 = require("./cold-start-steps");
|
|
34
35
|
const conversation_1 = require("./conversation");
|
|
35
36
|
const knowledge_tools_1 = require("./knowledge-tools");
|
|
36
37
|
const permission_mode_1 = require("./permission-mode");
|
|
37
38
|
const projection_1 = require("./projection");
|
|
38
39
|
const space_files_1 = require("./space-files");
|
|
40
|
+
/** A missing Pi consumption event must not hold the lease open for its full RPC timeout. */
|
|
41
|
+
const FINAL_STEER_ACK_WAIT_MS = 5_000;
|
|
39
42
|
/** How often the runner asks the adapter for new output when nothing pushes. */
|
|
40
43
|
const POLL_INTERVAL_MS = 2_000;
|
|
41
44
|
/**
|
|
@@ -381,6 +384,150 @@ class SessionRunner {
|
|
|
381
384
|
* three are what `answerPendingApproval` needs and cannot otherwise reach.
|
|
382
385
|
*/
|
|
383
386
|
turnContext = null;
|
|
387
|
+
pendingSteers = new Set();
|
|
388
|
+
deferredSteers = [];
|
|
389
|
+
steerDispatchChain = Promise.resolve();
|
|
390
|
+
nextSteerOrder = 0;
|
|
391
|
+
steeredPrompts = [];
|
|
392
|
+
steerEchoes = new Map();
|
|
393
|
+
steerTurnGeneration = 0;
|
|
394
|
+
steerAcknowledgementsClosed = false;
|
|
395
|
+
async waitForSteerAcknowledgements() {
|
|
396
|
+
const pending = [...this.pendingSteers];
|
|
397
|
+
if (pending.length === 0) {
|
|
398
|
+
this.steerAcknowledgementsClosed = true;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
let timeout;
|
|
402
|
+
try {
|
|
403
|
+
const settled = await Promise.race([
|
|
404
|
+
Promise.allSettled(pending).then(() => true),
|
|
405
|
+
new Promise((resolve) => {
|
|
406
|
+
timeout = setTimeout(() => resolve(false), FINAL_STEER_ACK_WAIT_MS);
|
|
407
|
+
})
|
|
408
|
+
]);
|
|
409
|
+
if (!settled) {
|
|
410
|
+
// Final ingest releases the lease and requeues every steer that has
|
|
411
|
+
// not been acknowledged. A late acknowledgement cannot claim it.
|
|
412
|
+
this.logger.warn(`[SessionRunner] steer-final-wait-timeout session=${this.turnContext?.sessionId ?? 'unknown'} pending=${pending.length}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
this.steerAcknowledgementsClosed = true;
|
|
417
|
+
if (timeout)
|
|
418
|
+
clearTimeout(timeout);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** A command can arrive on heartbeat or renewal while run() is in drain(). */
|
|
422
|
+
steerMessage(command) {
|
|
423
|
+
const context = this.turnContext;
|
|
424
|
+
if (!context) {
|
|
425
|
+
if (this.steerAcknowledgementsClosed) {
|
|
426
|
+
this.rejectLateSteer(command);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
this.deferredSteers.push(command);
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (context.sessionId !== command.sessionId ||
|
|
434
|
+
!context.adapter.steerUserMessage) {
|
|
435
|
+
this.rejectLateSteer(command);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (this.steerAcknowledgementsClosed) {
|
|
439
|
+
this.rejectLateSteer(command);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const generation = this.steerTurnGeneration;
|
|
443
|
+
const order = this.nextSteerOrder++;
|
|
444
|
+
// Preparation (not Pi consumption) is serial. Fast sends enter Pi's queue
|
|
445
|
+
// in transcript order, so steeringMode=all can use all at one boundary.
|
|
446
|
+
const prepared = this.steerDispatchChain.then(async () => {
|
|
447
|
+
let prompt = command.input.content;
|
|
448
|
+
if (context.workspaceDir &&
|
|
449
|
+
(command.input.attachments?.length ||
|
|
450
|
+
command.input.droppedAttachments?.length)) {
|
|
451
|
+
const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(command.input.attachments, context.workspaceDir, {
|
|
452
|
+
fetchImpl: this.deps.fetchAttachment,
|
|
453
|
+
logger: this.logger,
|
|
454
|
+
turnId: command.turnId
|
|
455
|
+
});
|
|
456
|
+
for (const dropped of command.input.droppedAttachments ?? []) {
|
|
457
|
+
sync.failed.push({
|
|
458
|
+
name: (0, attachments_1.sanitizeAttachmentName)(dropped.name),
|
|
459
|
+
reason: dropped.reason
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const note = (0, attachments_1.attachmentPromptNote)(sync);
|
|
463
|
+
if (note)
|
|
464
|
+
prompt = `${prompt}\n\n${note}`;
|
|
465
|
+
}
|
|
466
|
+
const seen = this.turnContext === context
|
|
467
|
+
? context.adapter.steerUserMessage(context.backendSessionId, prompt, context.config)
|
|
468
|
+
: Promise.resolve(false);
|
|
469
|
+
// Pi can emit the user echo before the consumption acknowledgement
|
|
470
|
+
// returns. Filter that echo as soon as the prompt enters its queue.
|
|
471
|
+
const echoes = this.steerEchoes.get(prompt) ?? [];
|
|
472
|
+
echoes.push(command.turnId);
|
|
473
|
+
this.steerEchoes.set(prompt, echoes);
|
|
474
|
+
return { prompt, seen };
|
|
475
|
+
});
|
|
476
|
+
this.steerDispatchChain = prepared.then(() => undefined, () => undefined);
|
|
477
|
+
const task = prepared
|
|
478
|
+
.then(async ({ prompt, seen: consumed }) => {
|
|
479
|
+
const seen = await consumed;
|
|
480
|
+
if (!seen ||
|
|
481
|
+
this.steerAcknowledgementsClosed ||
|
|
482
|
+
generation !== this.steerTurnGeneration) {
|
|
483
|
+
if (!seen)
|
|
484
|
+
this.logger.warn(`[SessionRunner] steer unconfirmed; queued for next turn (session=${command.sessionId}, turn=${command.turnId})`);
|
|
485
|
+
this.forgetSteerEcho(prompt, command.turnId);
|
|
486
|
+
// Pi rejected the steer, or its queue did not consume it. Reset the
|
|
487
|
+
// durable message now; release remains the fallback if this fails.
|
|
488
|
+
await command.acknowledge(false);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// Record Pi's consumption before the network ack. If the ack hangs,
|
|
492
|
+
// final ingest must still log what Pi saw in this turn. A queued
|
|
493
|
+
// fallback can replay that text under the at-least-once crash rule.
|
|
494
|
+
this.steeredPrompts.push({ order, text: prompt });
|
|
495
|
+
await command.acknowledge(true);
|
|
496
|
+
})
|
|
497
|
+
.catch((error) => {
|
|
498
|
+
this.logger.warn(`[SessionRunner] steer failed for ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
499
|
+
});
|
|
500
|
+
this.pendingSteers.add(task);
|
|
501
|
+
void task.finally(() => this.pendingSteers.delete(task));
|
|
502
|
+
}
|
|
503
|
+
rejectLateSteer(command) {
|
|
504
|
+
void command.acknowledge(false).catch((error) => {
|
|
505
|
+
this.logger.warn(`[SessionRunner] late steer reset failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
forgetSteerEcho(text, turnId) {
|
|
509
|
+
const echoes = this.steerEchoes.get(text);
|
|
510
|
+
if (!echoes)
|
|
511
|
+
return;
|
|
512
|
+
const index = echoes.indexOf(turnId);
|
|
513
|
+
if (index >= 0)
|
|
514
|
+
echoes.splice(index, 1);
|
|
515
|
+
if (echoes.length === 0)
|
|
516
|
+
this.steerEchoes.delete(text);
|
|
517
|
+
}
|
|
518
|
+
consumesSteerEcho(part) {
|
|
519
|
+
for (const [text, echoes] of this.steerEchoes) {
|
|
520
|
+
if (!this.isExactSteerEcho(part, text))
|
|
521
|
+
continue;
|
|
522
|
+
// Each matching user part consumes one steer, even when several steers
|
|
523
|
+
// have exactly the same text.
|
|
524
|
+
echoes.shift();
|
|
525
|
+
if (echoes.length === 0)
|
|
526
|
+
this.steerEchoes.delete(text);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
384
531
|
/** The approval currently shown to the user, if any. */
|
|
385
532
|
openApproval = null;
|
|
386
533
|
/** The transcript identity of an ordinary agent question, if one is open. */
|
|
@@ -438,6 +585,10 @@ class SessionRunner {
|
|
|
438
585
|
*/
|
|
439
586
|
async run(lease, signal) {
|
|
440
587
|
const { session } = lease;
|
|
588
|
+
const coldStart = new cold_start_steps_1.ColdStartSteps(session.sessionId, session.turnId, (line) => {
|
|
589
|
+
this.logger.log(line);
|
|
590
|
+
this.deps.coldStartLog?.(line);
|
|
591
|
+
});
|
|
441
592
|
const startedAt = new Date().toISOString();
|
|
442
593
|
const release = this.deps.notifier.keepAwake();
|
|
443
594
|
let workspaceDir = null;
|
|
@@ -464,12 +615,31 @@ class SessionRunner {
|
|
|
464
615
|
*/
|
|
465
616
|
let turnContent = session.input.content;
|
|
466
617
|
this.turnParts = [];
|
|
618
|
+
this.steerTurnGeneration++;
|
|
619
|
+
this.steerAcknowledgementsClosed = false;
|
|
620
|
+
this.pendingSteers.clear();
|
|
621
|
+
this.steeredPrompts = [];
|
|
622
|
+
this.steerDispatchChain = Promise.resolve();
|
|
623
|
+
this.nextSteerOrder = 0;
|
|
624
|
+
this.steerEchoes.clear();
|
|
467
625
|
this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
|
|
468
626
|
this.modelKeyExpiryNoticeShown = false;
|
|
469
627
|
try {
|
|
470
628
|
const refusal = modelGatewayRefusal(lease);
|
|
471
629
|
if (refusal)
|
|
472
630
|
throw new Error(refusal);
|
|
631
|
+
// The catalogue is advisory. Fetch it while the independent workspace
|
|
632
|
+
// is prepared, then install it before the backend process starts.
|
|
633
|
+
const endModels = coldStart.begin('model_catalogue');
|
|
634
|
+
const gatewayModels = session.agent.model.gatewayUrl
|
|
635
|
+
? (0, model_gateway_1.fetchGatewayModels)({
|
|
636
|
+
baseUrl: session.agent.model.gatewayUrl,
|
|
637
|
+
apiKey: lease.modelToken
|
|
638
|
+
}).finally(endModels)
|
|
639
|
+
: Promise.resolve([]);
|
|
640
|
+
if (!session.agent.model.gatewayUrl)
|
|
641
|
+
endModels();
|
|
642
|
+
const endWorkspace = coldStart.begin('workspace_prepare');
|
|
473
643
|
try {
|
|
474
644
|
workspaceDir = await this.deps.workspace.prepare({
|
|
475
645
|
sessionId: session.sessionId,
|
|
@@ -486,6 +656,7 @@ class SessionRunner {
|
|
|
486
656
|
},
|
|
487
657
|
refusalReason: session.workspace.refusalReason
|
|
488
658
|
});
|
|
659
|
+
endWorkspace();
|
|
489
660
|
const repositoryNames = session.workspace.requestedRepositories ??
|
|
490
661
|
(session.workspace.repositories ?? []).map(({ fullName }) => fullName);
|
|
491
662
|
for (const fullName of repositoryNames) {
|
|
@@ -495,6 +666,7 @@ class SessionRunner {
|
|
|
495
666
|
}
|
|
496
667
|
}
|
|
497
668
|
catch (caught) {
|
|
669
|
+
endWorkspace();
|
|
498
670
|
const reason = caught instanceof Error ? caught.message : String(caught);
|
|
499
671
|
const status = session.workspace.refusalReason ? 'refused' : 'failed';
|
|
500
672
|
const names = session.workspace.requestedRepositories ??
|
|
@@ -530,7 +702,13 @@ class SessionRunner {
|
|
|
530
702
|
*/
|
|
531
703
|
const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
|
|
532
704
|
if (workspaceDir) {
|
|
533
|
-
|
|
705
|
+
const endSpaceFiles = coldStart.begin('space_files_sync');
|
|
706
|
+
try {
|
|
707
|
+
await this.syncSpaceFilesForTurn(session, workspaceDir);
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
endSpaceFiles();
|
|
711
|
+
}
|
|
534
712
|
}
|
|
535
713
|
if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
|
|
536
714
|
const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
|
|
@@ -585,10 +763,7 @@ class SessionRunner {
|
|
|
585
763
|
* stop a turn whose model is already known, so we fall back to exactly
|
|
586
764
|
* the previous behaviour.
|
|
587
765
|
*/
|
|
588
|
-
const fetched = await
|
|
589
|
-
baseUrl: session.agent.model.gatewayUrl,
|
|
590
|
-
apiKey: lease.modelToken
|
|
591
|
-
});
|
|
766
|
+
const fetched = await gatewayModels;
|
|
592
767
|
const named = session.agent.model.model
|
|
593
768
|
? [
|
|
594
769
|
{
|
|
@@ -622,7 +797,13 @@ class SessionRunner {
|
|
|
622
797
|
this.deps.modelGateway?.set(null);
|
|
623
798
|
}
|
|
624
799
|
adapter = this.deps.getAdapter(session.agent.backend);
|
|
625
|
-
|
|
800
|
+
const endAdapter = coldStart.begin('adapter_initialize');
|
|
801
|
+
try {
|
|
802
|
+
await adapter.initialize();
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
endAdapter();
|
|
806
|
+
}
|
|
626
807
|
secretToken = this.setUpSecrets(session);
|
|
627
808
|
/*
|
|
628
809
|
* THE KNOWLEDGE TOOLS FOR THIS TURN (P9). Bound to this lease and this
|
|
@@ -638,6 +819,7 @@ class SessionRunner {
|
|
|
638
819
|
knowledgeUrl = await this.deps.knowledge.host.endpointFor(session.sessionId);
|
|
639
820
|
}
|
|
640
821
|
const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl);
|
|
822
|
+
config.coldStartLog = this.deps.coldStartLog;
|
|
641
823
|
/*
|
|
642
824
|
* REMEMBER THE CONVERSATION. This block is the fix for the defect that
|
|
643
825
|
* made this a chat product that could not hold a conversation: the
|
|
@@ -749,7 +931,13 @@ class SessionRunner {
|
|
|
749
931
|
}
|
|
750
932
|
}
|
|
751
933
|
if (!backendSessionId) {
|
|
752
|
-
|
|
934
|
+
const endBackend = coldStart.begin('backend_process_start');
|
|
935
|
+
try {
|
|
936
|
+
backendSessionId = await adapter.createSession(config);
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
endBackend();
|
|
940
|
+
}
|
|
753
941
|
this.deps.telemetry.record('provider.session.started', {
|
|
754
942
|
backend: session.agent.backend,
|
|
755
943
|
runtime: session.runtime
|
|
@@ -789,10 +977,18 @@ class SessionRunner {
|
|
|
789
977
|
: promptText;
|
|
790
978
|
replayed = !resumed && memory.messages.length > 0;
|
|
791
979
|
priorPartIdsForLog = priorPartIds;
|
|
792
|
-
|
|
980
|
+
const endPrompt = coldStart.begin('model_prompt_send');
|
|
981
|
+
try {
|
|
982
|
+
await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
endPrompt();
|
|
986
|
+
}
|
|
793
987
|
this.deps.telemetry.record('provider.turn.sent', {
|
|
794
988
|
backend: session.agent.backend
|
|
795
989
|
});
|
|
990
|
+
const endFirstOutput = coldStart.begin('first_agent_output');
|
|
991
|
+
const endFirstIngest = coldStart.begin('first_transcript_ingest');
|
|
796
992
|
/*
|
|
797
993
|
* WIRE 3 NEEDS A WAY IN. `run` is one long await, and the user's answer
|
|
798
994
|
* arrives on a control-plane command that the harness handles on a
|
|
@@ -804,8 +1000,12 @@ class SessionRunner {
|
|
|
804
1000
|
sessionId: session.sessionId,
|
|
805
1001
|
backendSessionId,
|
|
806
1002
|
adapter,
|
|
807
|
-
config
|
|
1003
|
+
config,
|
|
1004
|
+
workspaceDir
|
|
808
1005
|
};
|
|
1006
|
+
for (const command of this.deferredSteers.splice(0)) {
|
|
1007
|
+
this.steerMessage(command);
|
|
1008
|
+
}
|
|
809
1009
|
const drained = await this.drain(adapter, backendSessionId, config, session, signal,
|
|
810
1010
|
// RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
|
|
811
1011
|
// for "everything not in these sets", and on a resumed session that is
|
|
@@ -817,7 +1017,7 @@ class SessionRunner {
|
|
|
817
1017
|
resumed ? priorPartIds : new Set(),
|
|
818
1018
|
// THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
|
|
819
1019
|
// backend's own echo of it — see `drain`.
|
|
820
|
-
promptWithHostNote);
|
|
1020
|
+
promptWithHostNote, () => endFirstOutput(), () => endFirstIngest());
|
|
821
1021
|
partsProduced = drained.parts;
|
|
822
1022
|
if (signal.aborted) {
|
|
823
1023
|
stopReason = 'aborted';
|
|
@@ -903,6 +1103,7 @@ class SessionRunner {
|
|
|
903
1103
|
* turn happened to reset it.
|
|
904
1104
|
*/
|
|
905
1105
|
this.turnContext = null;
|
|
1106
|
+
this.deferredSteers.length = 0;
|
|
906
1107
|
this.closeApproval(session.sessionId);
|
|
907
1108
|
this.deps.knowledge?.host.bindTurn(session.sessionId, null);
|
|
908
1109
|
this.closeQuestion(session.sessionId);
|
|
@@ -997,9 +1198,18 @@ class SessionRunner {
|
|
|
997
1198
|
* but a conversation that silently doubles in size every turn, which is the
|
|
998
1199
|
* kind of defect that looks fine for a week.
|
|
999
1200
|
*/
|
|
1201
|
+
// Give Pi a short time to confirm consumption. Final ingest requeues any
|
|
1202
|
+
// unconfirmed steer; an absent event must not keep the lease alive.
|
|
1203
|
+
await this.waitForSteerAcknowledgements();
|
|
1000
1204
|
const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
|
|
1001
1205
|
const modelMessages = [
|
|
1002
1206
|
{ role: 'user', content: turnContent },
|
|
1207
|
+
// This store keeps one assistant message per turn. Steers retain their
|
|
1208
|
+
// order, but their exact interleaving with streamed assistant text is
|
|
1209
|
+
// not represented in this compact replay log.
|
|
1210
|
+
...this.steeredPrompts
|
|
1211
|
+
.sort((a, b) => a.order - b.order)
|
|
1212
|
+
.map(({ text }) => ({ role: 'user', content: text })),
|
|
1003
1213
|
...(spoken ? [{ role: 'assistant', content: spoken }] : [])
|
|
1004
1214
|
];
|
|
1005
1215
|
// Store locally FIRST, for the same reason parts are: the local copy is
|
|
@@ -1031,6 +1241,7 @@ class SessionRunner {
|
|
|
1031
1241
|
* the drain. That has not been true since the error path was added.
|
|
1032
1242
|
*/
|
|
1033
1243
|
await this.flush(session.sessionId, this.pending.splice(0), turn, true, modelMessages);
|
|
1244
|
+
coldStart.summary();
|
|
1034
1245
|
this.deps.telemetry.record('provider.turn.memory', {
|
|
1035
1246
|
resumed,
|
|
1036
1247
|
replayed,
|
|
@@ -1074,6 +1285,12 @@ class SessionRunner {
|
|
|
1074
1285
|
return false;
|
|
1075
1286
|
return sentPromptText.trim().startsWith(echoed);
|
|
1076
1287
|
}
|
|
1288
|
+
isExactSteerEcho(part, steeredPrompt) {
|
|
1289
|
+
if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user')
|
|
1290
|
+
return false;
|
|
1291
|
+
const text = part.content ?? part.text;
|
|
1292
|
+
return typeof text === 'string' && text.trim() === steeredPrompt.trim();
|
|
1293
|
+
}
|
|
1077
1294
|
/**
|
|
1078
1295
|
* Poll the adapter until the turn goes quiet or the signal aborts.
|
|
1079
1296
|
*
|
|
@@ -1101,7 +1318,7 @@ class SessionRunner {
|
|
|
1101
1318
|
*/
|
|
1102
1319
|
async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
|
|
1103
1320
|
/** The exact text `sendPrompt` was given this turn, for echo filtering. */
|
|
1104
|
-
sentPromptText = null) {
|
|
1321
|
+
sentPromptText = null, onFirstOutput, onFirstIngest) {
|
|
1105
1322
|
let providerError;
|
|
1106
1323
|
let timedOut = false;
|
|
1107
1324
|
let idleTimedOut = false;
|
|
@@ -1110,6 +1327,7 @@ class SessionRunner {
|
|
|
1110
1327
|
const seenPartIds = new Set(alreadySeenPartIds);
|
|
1111
1328
|
const partContentLengths = new Map();
|
|
1112
1329
|
let produced = 0;
|
|
1330
|
+
let firstOutputSeen = false;
|
|
1113
1331
|
let idleSeen = 0;
|
|
1114
1332
|
let wake = null;
|
|
1115
1333
|
adapter.onDataAvailable = () => {
|
|
@@ -1224,7 +1442,11 @@ class SessionRunner {
|
|
|
1224
1442
|
* are recorded by the answer path itself, with the `answer-…` part id.
|
|
1225
1443
|
*/
|
|
1226
1444
|
const freshParts = sentPromptText && sentPromptText.length > 0
|
|
1227
|
-
? parts.filter((part) =>
|
|
1445
|
+
? parts.filter((part) => {
|
|
1446
|
+
if (this.isEchoOfTurnPrompt(part, sentPromptText))
|
|
1447
|
+
return false;
|
|
1448
|
+
return !this.consumesSteerEcho(part);
|
|
1449
|
+
})
|
|
1228
1450
|
: parts;
|
|
1229
1451
|
/*
|
|
1230
1452
|
* WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
|
|
@@ -1236,8 +1458,14 @@ class SessionRunner {
|
|
|
1236
1458
|
*/
|
|
1237
1459
|
produced += await this.emitPendingApproval(adapter, backendSessionId, session);
|
|
1238
1460
|
if (freshParts.length > 0) {
|
|
1461
|
+
if (!firstOutputSeen) {
|
|
1462
|
+
firstOutputSeen = true;
|
|
1463
|
+
onFirstOutput?.();
|
|
1464
|
+
}
|
|
1239
1465
|
produced += this.record(session.sessionId, freshParts);
|
|
1240
1466
|
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1467
|
+
if (firstOutputSeen)
|
|
1468
|
+
onFirstIngest?.();
|
|
1241
1469
|
// Real output resets the quiet counter. A backend is idle BETWEEN
|
|
1242
1470
|
// tool calls as well as at the end of a turn, so ending on the first
|
|
1243
1471
|
// idle would truncate almost every transcript at the first tool.
|
|
@@ -2142,6 +2370,7 @@ class SessionRunner {
|
|
|
2142
2370
|
// would have made them no longer "extracted unchanged", which is worth
|
|
2143
2371
|
// more than the tidier name.
|
|
2144
2372
|
taskId: session.sessionId,
|
|
2373
|
+
turnId: session.turnId,
|
|
2145
2374
|
workspaceDir,
|
|
2146
2375
|
devServerPolicy: session.agent.devServerPolicy,
|
|
2147
2376
|
model: qualifiedModelId(session.agent.model),
|
package/dist/types.d.ts
CHANGED
|
@@ -333,7 +333,7 @@ export interface SessionCommand {
|
|
|
333
333
|
* 'abort' stops the turn; 'answer' resolves a pending question part;
|
|
334
334
|
* 'release-workspace' removes a checkout after the session ends.
|
|
335
335
|
*/
|
|
336
|
-
command: 'abort' | 'answer' | 'release-workspace';
|
|
336
|
+
command: 'abort' | 'answer' | 'release-workspace' | 'steer';
|
|
337
337
|
/** Set when command is 'answer' — the partId of the question being answered. */
|
|
338
338
|
partId?: string;
|
|
339
339
|
/** Set when command is 'answer' — the user's reply. */
|
|
@@ -350,6 +350,12 @@ export interface SessionCommand {
|
|
|
350
350
|
* questions, and ids are none of those things.
|
|
351
351
|
*/
|
|
352
352
|
optionId?: string;
|
|
353
|
+
/** A steer targets only this live lease. */
|
|
354
|
+
leaseId?: string;
|
|
355
|
+
/** The durable queued turn that becomes the fallback if steering fails. */
|
|
356
|
+
turnId?: string;
|
|
357
|
+
/** The message, with signed attachment URLs, from the control plane. */
|
|
358
|
+
input?: LeasedTurnInput;
|
|
353
359
|
/** De-duplication key. The same commandId must be applied at most once. */
|
|
354
360
|
commandId: string;
|
|
355
361
|
}
|
|
@@ -788,6 +794,8 @@ export interface IngestResponse {
|
|
|
788
794
|
* "the lease expired while it was working" bug.
|
|
789
795
|
*/
|
|
790
796
|
leaseExpiresAt: string;
|
|
797
|
+
/** Commands collected at this model/tool boundary. Older APIs omit it. */
|
|
798
|
+
sessionCommands?: SessionCommand[];
|
|
791
799
|
}
|
|
792
800
|
export interface IngestRejection {
|
|
793
801
|
partId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5-rc.2",
|
|
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",
|