From f20ae2f0a2e2ca1b0e0e194062ac8a136f3f6f96 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Fri, 13 Nov 2026 11:17:06 -0800 Subject: [PATCH] agents: forward flowStartedAt and firstAttemptAt in the TypeScript ConnectorStepContext Released connectors read call.context.flowStartedAt and firstAttemptAt. The tool Step's ConnectorStepContext did not forward them, so such an operation failed with a TypeError inside an agent tool Step. --- .../src/connectors/connector-step-context.ts | 19 ++++- .../connector-tool-source.integration.test.ts | 85 ++++++++++++++++++- .../connector-step-context-times-ts-345914.md | 7 ++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 changelog.d/agents-packages/connector-step-context-times-ts-345914.md diff --git a/agents/typescript/core/src/connectors/connector-step-context.ts b/agents/typescript/core/src/connectors/connector-step-context.ts index 9baf930e3e..191fa30053 100644 --- a/agents/typescript/core/src/connectors/connector-step-context.ts +++ b/agents/typescript/core/src/connectors/connector-step-context.ts @@ -114,7 +114,8 @@ export interface ConnectorStepHeartbeats { * agent session's tool Step keeps one text checkpoint per attempt and repeats it on every background heartbeat. * This context stores each heartbeat value as that text checkpoint, as JSON with sorted keys, and parses it on the * next attempt, so a marker survives the session's own heartbeats and a retry can read it. It exposes only the - * members the fence uses, plus the identities a Call needs; the agent core's conformance tests pin that list. + * members the fence uses, plus the identities and times released connectors read; the agent core's conformance + * tests pin that list. * * `ConnectorToolSource` builds one per tool Step attempt. Applications do not construct it. * @since 0.4.0 @@ -142,6 +143,22 @@ export class ConnectorStepContext { return this.context.runId; } + /** + * When the current Flow run started, the same on every Step and attempt of the run. + * @since 0.6.5 + */ + public get flowStartedAt(): Date { + return this.context.flowStartedAt; + } + + /** + * When the tool Step's first attempt became pending, the same on every attempt. + * @since 0.6.5 + */ + public get firstAttemptAt(): Date { + return this.context.firstAttemptAt; + } + /** The tool Step's execution ID, the same on every attempt. */ public get stepExecutionId(): string { return this.context.stepExecutionId; diff --git a/agents/typescript/core/test/integ/connector-tool-source.integration.test.ts b/agents/typescript/core/test/integ/connector-tool-source.integration.test.ts index 61599e22db..968399b561 100644 --- a/agents/typescript/core/test/integ/connector-tool-source.integration.test.ts +++ b/agents/typescript/core/test/integ/connector-tool-source.integration.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { describe, test } from "node:test"; import type { AsyncContext } from "@superdurable/dex"; -import { createCall, deriveCallID, type Call } from "@superdurable/connectors-sdk"; +import { createCall, deriveCallID, newQueryBranch, type Call, type QueryAttempt } from "@superdurable/connectors-sdk"; import { Fence } from "@superdurable/connectors-sdk/singledispatch"; import { @@ -32,6 +32,7 @@ import { type ConnectorPackageManifest, } from "../../src/connectors/index.js"; import { isProvenNotSent, lowerCamelToSnake, mayHaveDispatched } from "../../src/connectors/connector-tool-source.js"; +import type { DeferredToolExecution } from "../../src/agent-framework-runner.js"; import { buildConnectorToolApprovalDisplay, isCredentialLikeName, @@ -390,7 +391,7 @@ describe("connector tool approval", () => { }); describe("the Step context the single-dispatch fence uses", () => { - test("it exposes only what the fence reads, plus the Call identities", () => { + test("it exposes only what the fence reads, plus the identities and times connectors read", () => { const members = Object.getOwnPropertyNames(ConnectorStepContext.prototype) .filter((name) => name !== "constructor") .sort(); @@ -398,7 +399,9 @@ describe("the Step context the single-dispatch fence uses", () => { "attempt", "cancellationSignal", "deadline", + "firstAttemptAt", "flowId", + "flowStartedAt", "getLastHeartbeatValue", "hasLastHeartbeatValue", "recordHeartbeat", @@ -407,6 +410,33 @@ describe("the Step context the single-dispatch fence uses", () => { ]); }); + test("an operation reads the Flow start and first attempt times through ConnectorToolSource", async () => { + const source = new ConnectorToolSource([ + connectorTool(new StepTimesBalanceOperation(), ledgerConnection, { + manifest, + inputValidator: GetBalanceInputSchema, + name: "get_balance", + }), + ]); + const flowStartedAt = new Date("2026-11-13T08:00:00Z"); + const firstAttemptAt = new Date("2026-11-13T08:00:05Z"); + const live = { + flowId: "session-1", + runId: "run-1", + flowStartedAt, + stepExecutionId: "CallTool-3", + firstAttemptAt, + attempt: 1, + deadline: new Date(Date.now() + 60_000), + cancellationSignal: new AbortController().signal, + } as unknown as AsyncContext; + const result = await source.executeDeferredToolCall(stepTimesExecution(live)); + assert.deepEqual(JSON.parse(result.output), { + accountId: "acct-1", + amount: firstAttemptAt.getTime() - flowStartedAt.getTime(), + }); + }); + test("every fence method keeps its marker through the session checkpoint", async () => { const first = new SessionCheckpoints(); const fence = new Fence(fencedCall(1, undefined, first), 30_000); @@ -618,3 +648,54 @@ function fencedCallWithContext(attempt: number, previous: string | undefined, ch }); return { call, context }; } + +/** getBalance that reports the Step times it reads, as released connectors read them. */ +class StepTimesBalanceOperation extends GetBalanceOperation { + public constructor() { + super(provider); + } + + public override async invoke( + call: Call, + input: { readonly accountId: string }, + ): Promise> { + const amount = call.context.firstAttemptAt.getTime() - call.context.flowStartedAt.getTime(); + return newQueryBranch("found", { accountId: input.accountId, amount }); + } +} + +function stepTimesExecution(context: AsyncContext): DeferredToolExecution { + return { + flowId: "session-1", + toolCall: { + toolCallId: "call-1", + turnId: "turn-1", + toolName: "get_balance", + arguments: '{"accountId": "acct-1"}', + effect: "read", + requiresApproval: false, + status: "approved", + output: null, + error: null, + rejectionMessage: null, + deferralOrder: 0, + toolSourceReference: JSON.stringify({ tool: "get_balance" }), + }, + idempotencyKey: "session-1/turn-1/call-1", + attempt: 1, + events: { + turnId: "turn-1", + attempt: 1, + writeEvent: () => undefined, + writeTextDelta: () => undefined, + writeReasoningDelta: () => undefined, + writeToolCallStarted: () => undefined, + writeToolCallFinished: () => undefined, + }, + cancellationSignal: context.cancellationSignal, + deadline: context.deadline, + heartbeat: () => Promise.resolve(), + supportsToolTasks: false, + context, + } as unknown as DeferredToolExecution; +} diff --git a/changelog.d/agents-packages/connector-step-context-times-ts-345914.md b/changelog.d/agents-packages/connector-step-context-times-ts-345914.md new file mode 100644 index 0000000000..289b1cf940 --- /dev/null +++ b/changelog.d/agents-packages/connector-step-context-times-ts-345914.md @@ -0,0 +1,7 @@ +--- +section: Fixed +--- +- `@superdurable/dex-agents`: `ConnectorStepContext` now exposes + `flowStartedAt` and `firstAttemptAt` from the tool Step's context. A + connector operation that reads either one, such as a Gmail send, no longer + fails inside an agent tool Step.