Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion agents/typescript/core/src/connectors/connector-step-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -390,15 +391,17 @@ 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();
assert.deepEqual(members, [
"attempt",
"cancellationSignal",
"deadline",
"firstAttemptAt",
"flowId",
"flowStartedAt",
"getLastHeartbeatValue",
"hasLastHeartbeatValue",
"recordHeartbeat",
Expand All @@ -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);
Expand Down Expand Up @@ -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<QueryAttempt<{ accountId: string; amount: number }>> {
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;
}
Original file line number Diff line number Diff line change
@@ -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.
Loading