Skip to content

Commit 974cc2d

Browse files
agents: forward flowStartedAt and firstAttemptAt through ConnectorStepContext (#1799)
1 parent 129a470 commit 974cc2d

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

‎agents/typescript/core/src/connectors/connector-step-context.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ export interface ConnectorStepHeartbeats {
114114
* agent session's tool Step keeps one text checkpoint per attempt and repeats it on every background heartbeat.
115115
* This context stores each heartbeat value as that text checkpoint, as JSON with sorted keys, and parses it on the
116116
* next attempt, so a marker survives the session's own heartbeats and a retry can read it. It exposes only the
117-
* members the fence uses, plus the identities a Call needs; the agent core's conformance tests pin that list.
117+
* members the fence uses, plus the identities and times released connectors read; the agent core's conformance
118+
* tests pin that list.
118119
*
119120
* `ConnectorToolSource` builds one per tool Step attempt. Applications do not construct it.
120121
* @since 0.4.0
@@ -142,6 +143,22 @@ export class ConnectorStepContext {
142143
return this.context.runId;
143144
}
144145

146+
/**
147+
* When the current Flow run started, the same on every Step and attempt of the run.
148+
* @since 0.6.5
149+
*/
150+
public get flowStartedAt(): Date {
151+
return this.context.flowStartedAt;
152+
}
153+
154+
/**
155+
* When the tool Step's first attempt became pending, the same on every attempt.
156+
* @since 0.6.5
157+
*/
158+
public get firstAttemptAt(): Date {
159+
return this.context.firstAttemptAt;
160+
}
161+
145162
/** The tool Step's execution ID, the same on every attempt. */
146163
public get stepExecutionId(): string {
147164
return this.context.stepExecutionId;

‎agents/typescript/core/test/integ/connector-tool-source.integration.test.ts‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { join } from "node:path";
1616
import { describe, test } from "node:test";
1717

1818
import type { AsyncContext } from "@superdurable/dex";
19-
import { createCall, deriveCallID, type Call } from "@superdurable/connectors-sdk";
19+
import { createCall, deriveCallID, newQueryBranch, type Call, type QueryAttempt } from "@superdurable/connectors-sdk";
2020
import { Fence } from "@superdurable/connectors-sdk/singledispatch";
2121

2222
import {
@@ -32,6 +32,7 @@ import {
3232
type ConnectorPackageManifest,
3333
} from "../../src/connectors/index.js";
3434
import { isProvenNotSent, lowerCamelToSnake, mayHaveDispatched } from "../../src/connectors/connector-tool-source.js";
35+
import type { DeferredToolExecution } from "../../src/agent-framework-runner.js";
3536
import {
3637
buildConnectorToolApprovalDisplay,
3738
isCredentialLikeName,
@@ -390,15 +391,17 @@ describe("connector tool approval", () => {
390391
});
391392

392393
describe("the Step context the single-dispatch fence uses", () => {
393-
test("it exposes only what the fence reads, plus the Call identities", () => {
394+
test("it exposes only what the fence reads, plus the identities and times connectors read", () => {
394395
const members = Object.getOwnPropertyNames(ConnectorStepContext.prototype)
395396
.filter((name) => name !== "constructor")
396397
.sort();
397398
assert.deepEqual(members, [
398399
"attempt",
399400
"cancellationSignal",
400401
"deadline",
402+
"firstAttemptAt",
401403
"flowId",
404+
"flowStartedAt",
402405
"getLastHeartbeatValue",
403406
"hasLastHeartbeatValue",
404407
"recordHeartbeat",
@@ -407,6 +410,33 @@ describe("the Step context the single-dispatch fence uses", () => {
407410
]);
408411
});
409412

413+
test("an operation reads the Flow start and first attempt times through ConnectorToolSource", async () => {
414+
const source = new ConnectorToolSource([
415+
connectorTool(new StepTimesBalanceOperation(), ledgerConnection, {
416+
manifest,
417+
inputValidator: GetBalanceInputSchema,
418+
name: "get_balance",
419+
}),
420+
]);
421+
const flowStartedAt = new Date("2026-11-13T08:00:00Z");
422+
const firstAttemptAt = new Date("2026-11-13T08:00:05Z");
423+
const live = {
424+
flowId: "session-1",
425+
runId: "run-1",
426+
flowStartedAt,
427+
stepExecutionId: "CallTool-3",
428+
firstAttemptAt,
429+
attempt: 1,
430+
deadline: new Date(Date.now() + 60_000),
431+
cancellationSignal: new AbortController().signal,
432+
} as unknown as AsyncContext;
433+
const result = await source.executeDeferredToolCall(stepTimesExecution(live));
434+
assert.deepEqual(JSON.parse(result.output), {
435+
accountId: "acct-1",
436+
amount: firstAttemptAt.getTime() - flowStartedAt.getTime(),
437+
});
438+
});
439+
410440
test("every fence method keeps its marker through the session checkpoint", async () => {
411441
const first = new SessionCheckpoints();
412442
const fence = new Fence(fencedCall(1, undefined, first), 30_000);
@@ -618,3 +648,54 @@ function fencedCallWithContext(attempt: number, previous: string | undefined, ch
618648
});
619649
return { call, context };
620650
}
651+
652+
/** getBalance that reports the Step times it reads, as released connectors read them. */
653+
class StepTimesBalanceOperation extends GetBalanceOperation {
654+
public constructor() {
655+
super(provider);
656+
}
657+
658+
public override async invoke(
659+
call: Call,
660+
input: { readonly accountId: string },
661+
): Promise<QueryAttempt<{ accountId: string; amount: number }>> {
662+
const amount = call.context.firstAttemptAt.getTime() - call.context.flowStartedAt.getTime();
663+
return newQueryBranch("found", { accountId: input.accountId, amount });
664+
}
665+
}
666+
667+
function stepTimesExecution(context: AsyncContext): DeferredToolExecution {
668+
return {
669+
flowId: "session-1",
670+
toolCall: {
671+
toolCallId: "call-1",
672+
turnId: "turn-1",
673+
toolName: "get_balance",
674+
arguments: '{"accountId": "acct-1"}',
675+
effect: "read",
676+
requiresApproval: false,
677+
status: "approved",
678+
output: null,
679+
error: null,
680+
rejectionMessage: null,
681+
deferralOrder: 0,
682+
toolSourceReference: JSON.stringify({ tool: "get_balance" }),
683+
},
684+
idempotencyKey: "session-1/turn-1/call-1",
685+
attempt: 1,
686+
events: {
687+
turnId: "turn-1",
688+
attempt: 1,
689+
writeEvent: () => undefined,
690+
writeTextDelta: () => undefined,
691+
writeReasoningDelta: () => undefined,
692+
writeToolCallStarted: () => undefined,
693+
writeToolCallFinished: () => undefined,
694+
},
695+
cancellationSignal: context.cancellationSignal,
696+
deadline: context.deadline,
697+
heartbeat: () => Promise.resolve(),
698+
supportsToolTasks: false,
699+
context,
700+
} as unknown as DeferredToolExecution;
701+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
section: Fixed
3+
---
4+
- `@superdurable/dex-agents`: `ConnectorStepContext` now exposes
5+
`flowStartedAt` and `firstAttemptAt` from the tool Step's context. A
6+
connector operation that reads either one, such as a Gmail send, no longer
7+
fails inside an agent tool Step.

0 commit comments

Comments
 (0)