@cursor/july 0.1.83 → 0.1.84

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.
Files changed (93) hide show
  1. package/dist/docs/404.html +1 -1
  2. package/dist/docs/ab.html +2 -2
  3. package/dist/docs/assets/{app.6THHGRH-.js → app.kqdC1N7p.js} +1 -1
  4. package/dist/docs/assets/chunks/@localSearchIndexroot.BqhvUA76.js +1 -0
  5. package/dist/docs/assets/chunks/{VPLocalSearchBox.BpHB9ulP.js → VPLocalSearchBox.DXDE2WgG.js} +1 -1
  6. package/dist/docs/assets/chunks/{theme.DBJvJznv.js → theme.DFYqbyta.js} +2 -2
  7. package/dist/docs/building-with-agents.html +2 -2
  8. package/dist/docs/concepts.html +2 -2
  9. package/dist/docs/deployment.html +2 -2
  10. package/dist/docs/evals.html +2 -2
  11. package/dist/docs/example-agents/approval-buddy.html +2 -2
  12. package/dist/docs/example-agents/benny.html +2 -2
  13. package/dist/docs/example-agents/bugbot.html +2 -2
  14. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  15. package/dist/docs/example-agents/codeowners-review.html +2 -2
  16. package/dist/docs/example-agents/concierge.html +2 -2
  17. package/dist/docs/example-agents/fsd.html +2 -2
  18. package/dist/docs/example-agents/index.html +2 -2
  19. package/dist/docs/example-agents/knowledge-base.html +2 -2
  20. package/dist/docs/example-agents/oncall.html +2 -2
  21. package/dist/docs/example-agents/security-reviewer.html +2 -2
  22. package/dist/docs/example-agents/slack-agent.html +2 -2
  23. package/dist/docs/example-agents/weather-agent.html +2 -2
  24. package/dist/docs/guides/agent-to-agent.html +2 -2
  25. package/dist/docs/guides/cloud-runtime.html +2 -2
  26. package/dist/docs/guides/convert-automation.html +2 -2
  27. package/dist/docs/guides/github.html +2 -2
  28. package/dist/docs/guides/human-in-the-loop.html +2 -2
  29. package/dist/docs/guides/mcp-oauth.html +2 -2
  30. package/dist/docs/guides/opentelemetry.html +2 -2
  31. package/dist/docs/guides/slack.html +2 -2
  32. package/dist/docs/guides/webhooks.html +2 -2
  33. package/dist/docs/hillclimbing.html +2 -2
  34. package/dist/docs/index.html +2 -2
  35. package/dist/docs/quickstart.html +2 -2
  36. package/dist/docs/reference/agent-config.html +2 -2
  37. package/dist/docs/reference/artifacts.html +2 -2
  38. package/dist/docs/reference/channels.html +2 -2
  39. package/dist/docs/reference/cli.html +2 -2
  40. package/dist/docs/reference/connections.html +2 -2
  41. package/dist/docs/reference/hooks.html +2 -2
  42. package/dist/docs/reference/http-api.html +2 -2
  43. package/dist/docs/reference/instructions.html +2 -2
  44. package/dist/docs/reference/playground.html +2 -2
  45. package/dist/docs/reference/project-layout.html +2 -2
  46. package/dist/docs/reference/prompt.html +2 -2
  47. package/dist/docs/reference/schedules.html +2 -2
  48. package/dist/docs/reference/sessions.html +2 -2
  49. package/dist/docs/reference/skills.html +2 -2
  50. package/dist/docs/reference/subagents.html +2 -2
  51. package/dist/docs/reference/tools.html +2 -2
  52. package/dist/docs/scaffolding-agents.html +2 -2
  53. package/dist/docs/storage.html +2 -2
  54. package/dist/docs/templates/agentic-owners.html +2 -2
  55. package/dist/docs/templates/demo.html +2 -2
  56. package/dist/docs/templates/pr-autofixer.html +2 -2
  57. package/dist/docs/templates/security-reviewer.html +2 -2
  58. package/dist/docs/templates/triage.html +2 -2
  59. package/dist/docs/troubleshooting.html +2 -2
  60. package/dist/internal/discovery.d.ts.map +1 -1
  61. package/dist/internal/discovery.js +16 -1
  62. package/dist/internal/event-mapper.d.ts +7 -1
  63. package/dist/internal/event-mapper.d.ts.map +1 -1
  64. package/dist/internal/event-mapper.js +8 -8
  65. package/dist/internal/http-channel.d.ts.map +1 -1
  66. package/dist/internal/http-channel.js +1 -1
  67. package/dist/internal/mcp-endpoint.js +9 -0
  68. package/dist/internal/prompt-context.d.ts +4 -1
  69. package/dist/internal/prompt-context.d.ts.map +1 -1
  70. package/dist/internal/prompt-context.js +20 -2
  71. package/dist/internal/review-comments.js +15 -7
  72. package/dist/internal/session-engine.d.ts +28 -0
  73. package/dist/internal/session-engine.d.ts.map +1 -1
  74. package/dist/internal/session-engine.js +99 -42
  75. package/dist/internal/tool-policy.d.ts +50 -0
  76. package/dist/internal/tool-policy.d.ts.map +1 -0
  77. package/dist/internal/tool-policy.js +51 -0
  78. package/dist/tools.d.ts +16 -2
  79. package/dist/tools.d.ts.map +1 -1
  80. package/dist/types.d.ts +67 -0
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/internal/discovery.ts +20 -0
  84. package/src/internal/event-mapper.ts +10 -1
  85. package/src/internal/http-channel.ts +1 -0
  86. package/src/internal/mcp-endpoint.ts +9 -0
  87. package/src/internal/prompt-context.ts +25 -2
  88. package/src/internal/review-comments.ts +17 -7
  89. package/src/internal/session-engine.ts +120 -31
  90. package/src/internal/tool-policy.ts +92 -0
  91. package/src/tools.ts +19 -0
  92. package/src/types.ts +73 -0
  93. package/dist/docs/assets/chunks/@localSearchIndexroot.D9uScPWb.js +0 -1
@@ -134,6 +134,7 @@ import { SessionStore } from "./session-store.js";
134
134
  import { syncSkillsToStore } from "./skills-sync.js";
135
135
  import { StorageCoordinator } from "./storage-coordinator.js";
136
136
  import type { FrameworkSessionStore } from "./storage-roles.js";
137
+ import { decideToolCall } from "./tool-policy.js";
137
138
  import { normalizeToolResult, toolCallErrorMessage } from "./tool-result.js";
138
139
  import {
139
140
  type EstimateTurnCostFn,
@@ -333,6 +334,15 @@ export class SessionEngine {
333
334
  private readonly activeTurns = new Map<string, Promise<TurnOutcome>>();
334
335
  /** Context used by the cloud turn's HTTP MCP callbacks. */
335
336
  private readonly activeToolContexts = new Map<string, ActiveToolContext>();
337
+ /**
338
+ * Tool call ids the host answered instead of running, per session.
339
+ *
340
+ * The mapper emits the result event from the harness's view of a finished
341
+ * call, which cannot tell an answered call from a real one — so the decision
342
+ * point records the id here and the mapper stamps the event from it.
343
+ * Registered only while a turn can invoke a tool.
344
+ */
345
+ private readonly stubbedCallIds = new Map<string, Set<string>>();
336
346
  /**
337
347
  * Delivered-nudge count per session at the last tool boundary. The carrier
338
348
  * drains out-of-process, so a growing count is how the engine learns a
@@ -882,6 +892,7 @@ export class SessionEngine {
882
892
  channelId,
883
893
  mode: sendOptions.mode ?? "chat",
884
894
  purpose,
895
+ ...(sendOptions.dryRun === true ? { dryRun: true } : {}),
885
896
  continuationKey: sendOptions.continuationToken ?? newContinuationKey(),
886
897
  auth,
887
898
  title: sendOptions.title,
@@ -1596,7 +1607,10 @@ export class SessionEngine {
1596
1607
  );
1597
1608
  }
1598
1609
  };
1599
- const mapper = new TurnEventMapper(emit);
1610
+ // Read by closure, so the mapper still sees it while draining after the
1611
+ // registry entry below is gone.
1612
+ const stubbed = new Set<string>();
1613
+ const mapper = new TurnEventMapper(emit, (callId) => stubbed.has(callId));
1600
1614
 
1601
1615
  // Take a turn slot before any work happens. When every slot is busy the
1602
1616
  // turn waits FIFO — durable `turn.queued` marks the wait — and a
@@ -1706,10 +1720,12 @@ export class SessionEngine {
1706
1720
  pendingImages
1707
1721
  );
1708
1722
  this.activeToolContexts.set(sessionId, { record, turnId, emit });
1723
+ this.stubbedCallIds.set(sessionId, stubbed);
1709
1724
  try {
1710
1725
  outcome = await this.runner.runTurn(request);
1711
1726
  } finally {
1712
1727
  this.activeToolContexts.delete(sessionId);
1728
+ this.stubbedCallIds.delete(sessionId);
1713
1729
  }
1714
1730
  } catch (error) {
1715
1731
  this.approvals.cancelSession(sessionId, "deny");
@@ -2434,6 +2450,21 @@ export class SessionEngine {
2434
2450
  emit: args.emit,
2435
2451
  };
2436
2452
  try {
2453
+ // Before the approval gate: a dry run is unattended, so parking a write
2454
+ // for a human would hang the turn rather than answer it.
2455
+ const decision = decideToolCall({
2456
+ dryRun: args.record.dryRun === true,
2457
+ effect: args.tool.definition.effect,
2458
+ dryRunResult: args.tool.definition.dryRunResult,
2459
+ input: validation.value,
2460
+ });
2461
+ if (decision.kind === "answer") {
2462
+ this.stubbedCallIds.get(args.record.sessionId)?.add(toolCallId);
2463
+ this.logger(
2464
+ `[session] tool ${args.tool.name} answered by host (${toolCallId})`
2465
+ );
2466
+ return normalizeToolResult(decision.result);
2467
+ }
2437
2468
  const needsGate = await evaluateNeedsApproval(
2438
2469
  args.tool.definition.needsApproval,
2439
2470
  validation.value
@@ -2716,10 +2747,21 @@ export class SessionEngine {
2716
2747
  });
2717
2748
  }
2718
2749
 
2750
+ // A separate body from the model path, so it asks the same policy: a
2751
+ // session-bound `POST /v1/tools/:name`, `call --session`, or
2752
+ // `handle.callTool` performs a write just as readily.
2753
+ const decision = decideToolCall({
2754
+ dryRun: record?.dryRun === true,
2755
+ effect: tool.definition.effect,
2756
+ dryRunResult: tool.definition.dryRunResult,
2757
+ input: validation.value,
2758
+ });
2759
+
2719
2760
  const started = Date.now();
2720
2761
  this.logger(`[session] tool ${toolName} start (direct ${callId})`);
2721
2762
  let result: unknown;
2722
2763
  let isError: boolean;
2764
+ const stubbed = decision.kind === "answer";
2723
2765
  const live = record;
2724
2766
  const emit =
2725
2767
  live === undefined
@@ -2731,35 +2773,43 @@ export class SessionEngine {
2731
2773
  }).catch(() => {});
2732
2774
  };
2733
2775
  try {
2734
- result = normalizeToolResult(
2735
- await execute(validation.value as never, {
2736
- toolCallId: callId,
2737
- session,
2738
- workspaceDir,
2739
- stateRoot: this.stateRoot,
2740
- host: this.hostForSession(record?.sessionId),
2741
- // Scratch calls have no real session; their facade stays unbound.
2742
- artifacts:
2743
- record === undefined
2744
- ? this.artifactsApi()
2745
- : this.artifactsApi({
2746
- sessionId: record.sessionId,
2747
- turnId: callId,
2748
- }),
2749
- send: (channelId, message, sendOptions = {}) =>
2750
- this.send(channelId, message, {
2751
- ...sendOptions,
2752
- auth:
2753
- sendOptions.auth !== undefined
2754
- ? sendOptions.auth
2755
- : session.auth,
2756
- }),
2757
- getSession: (channelId, sessionId) =>
2758
- this.getChannelSession(channelId, sessionId),
2759
- emit,
2760
- })
2761
- );
2762
- isError = isErrorEnvelope(result);
2776
+ if (decision.kind === "answer") {
2777
+ this.logger(
2778
+ `[session] tool ${toolName} answered by host (direct ${callId})`
2779
+ );
2780
+ result = normalizeToolResult(decision.result);
2781
+ isError = false;
2782
+ } else {
2783
+ result = normalizeToolResult(
2784
+ await execute(validation.value as never, {
2785
+ toolCallId: callId,
2786
+ session,
2787
+ workspaceDir,
2788
+ stateRoot: this.stateRoot,
2789
+ host: this.hostForSession(record?.sessionId),
2790
+ // Scratch calls have no real session; their facade stays unbound.
2791
+ artifacts:
2792
+ record === undefined
2793
+ ? this.artifactsApi()
2794
+ : this.artifactsApi({
2795
+ sessionId: record.sessionId,
2796
+ turnId: callId,
2797
+ }),
2798
+ send: (channelId, message, sendOptions = {}) =>
2799
+ this.send(channelId, message, {
2800
+ ...sendOptions,
2801
+ auth:
2802
+ sendOptions.auth !== undefined
2803
+ ? sendOptions.auth
2804
+ : session.auth,
2805
+ }),
2806
+ getSession: (channelId, sessionId) =>
2807
+ this.getChannelSession(channelId, sessionId),
2808
+ emit,
2809
+ })
2810
+ );
2811
+ isError = isErrorEnvelope(result);
2812
+ }
2763
2813
  } catch (error) {
2764
2814
  this.logger(
2765
2815
  `[session] tool ${toolName} failed (direct ${callId}): ${describeError(error)}`
@@ -2789,7 +2839,13 @@ export class SessionEngine {
2789
2839
  await this.appendEvent(record.sessionId, {
2790
2840
  type: "action.result",
2791
2841
  turnId: callId,
2792
- data: { callId, toolName, output: result, isError },
2842
+ data: {
2843
+ callId,
2844
+ toolName,
2845
+ output: result,
2846
+ isError,
2847
+ ...(stubbed ? { stubbed: true as const } : {}),
2848
+ },
2793
2849
  });
2794
2850
  }
2795
2851
 
@@ -2805,6 +2861,38 @@ export class SessionEngine {
2805
2861
  };
2806
2862
  }
2807
2863
 
2864
+ /**
2865
+ * Whether a dry run answers a bridged MCP call, for the cloud host bridge —
2866
+ * the one tool path that does not reach {@link executeServerTool}, because
2867
+ * it forwards to `host.mcp` instead of invoking a tool body.
2868
+ *
2869
+ * A bridged tool carries no `defineTool`, so it declares no effect, and the
2870
+ * policy reads an undeclared tool as a write. In a dry run that answers
2871
+ * bridged reads too; resolving effects from a server's own listing is the
2872
+ * next unit's work.
2873
+ */
2874
+ bridgedCallAnswer(args: {
2875
+ sessionId: string;
2876
+ toolName: string;
2877
+ }): { answered: true; result: SDKCustomToolResult } | { answered: false } {
2878
+ // Read off the in-flight turn's context rather than the session store:
2879
+ // the bridge already refuses a call with no active turn, so the record is
2880
+ // in memory. That keeps this free for the sessions that are not dry runs,
2881
+ // which is every session today.
2882
+ const record = this.activeToolContexts.get(args.sessionId)?.record;
2883
+ const decision = decideToolCall({
2884
+ dryRun: record?.dryRun === true,
2885
+ effect: undefined,
2886
+ });
2887
+ if (decision.kind === "run") {
2888
+ return { answered: false };
2889
+ }
2890
+ this.logger(
2891
+ `[session] bridged tool ${args.toolName} answered by host (${args.sessionId})`
2892
+ );
2893
+ return { answered: true, result: normalizeToolResult(decision.result) };
2894
+ }
2895
+
2808
2896
  // ==========================================================================
2809
2897
  // Events: append, dispatch to channel handlers + hooks
2810
2898
  // ==========================================================================
@@ -3445,6 +3533,7 @@ export class SessionEngine {
3445
3533
  channelId: record.channelId,
3446
3534
  mode: record.mode,
3447
3535
  purpose: record.purpose === "eval" ? "eval" : "live",
3536
+ ...(record.dryRun === true ? { dryRun: true } : {}),
3448
3537
  auth: record.auth,
3449
3538
  continuationKey: record.continuationKey,
3450
3539
  ...(record.abs === undefined ? {} : { abs: record.abs }),
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The one decision every tool call passes through.
3
+ *
4
+ * A tool body can be reached by more than one path — the model's own calls on
5
+ * a local turn, a cloud turn's calls over the MCP endpoint, a direct
6
+ * `callTool`, the host connection bridge. Each of those is a separate code
7
+ * path, and a guarantee that holds on some of them is not a guarantee. So the
8
+ * decision lives here, in one pure function, and every path asks it rather
9
+ * than re-implementing it.
10
+ */
11
+
12
+ import type {
13
+ ToolEffect,
14
+ ToolEffectDeclaration,
15
+ ToolExecuteResult,
16
+ } from "../types.js";
17
+
18
+ /** What the host does with one tool call. */
19
+ export type ToolDecision =
20
+ /** Run the tool body. */
21
+ | { kind: "run" }
22
+ /** Do not run it; return this to the model instead. */
23
+ | { kind: "answer"; result: ToolExecuteResult };
24
+
25
+ /** What a policy decision needs to know about the call. */
26
+ export interface ToolCallFacts {
27
+ /** The session answers writes instead of running them. */
28
+ dryRun: boolean;
29
+ /** The tool's declaration, if it made one. */
30
+ effect: ToolEffectDeclaration<never> | undefined;
31
+ /** What a stubbed write should answer with, if the tool supplied one. */
32
+ dryRunResult?: ToolExecuteResult | ((input: never) => ToolExecuteResult);
33
+ /**
34
+ * Validated input, as the tool body would receive it. Only read by a
35
+ * per-call `effect` predicate and by a `dryRunResult` function, so a caller
36
+ * with neither — a bridged MCP tool, which carries no declaration at all —
37
+ * can omit it.
38
+ */
39
+ input?: unknown;
40
+ }
41
+
42
+ /**
43
+ * What a stubbed write answers. Deliberately not self-describing: a model told
44
+ * mid-turn that its writes are not real starts hedging its conclusions, and
45
+ * the conclusion is the thing a dry run exists to read. Tell the agent once,
46
+ * in its opening prompt, not in a tool result.
47
+ */
48
+ const ACKNOWLEDGED = "Operation acknowledged.";
49
+
50
+ /**
51
+ * The effect of one specific call.
52
+ *
53
+ * Undeclared resolves to `"write"`. A dry run promises that nothing outside
54
+ * the session changed, and it cannot promise that about a tool it knows
55
+ * nothing about — so the unknown case is the one that costs a stubbed read
56
+ * rather than the one that leaks a real write.
57
+ */
58
+ export function resolveEffect(
59
+ effect: ToolEffectDeclaration<never> | undefined,
60
+ input: unknown
61
+ ): ToolEffect {
62
+ const declared =
63
+ typeof effect === "function" ? effect(input as never) : effect;
64
+ return declared === "read" ? "read" : "write";
65
+ }
66
+
67
+ /** Decide what to do with one tool call. Pure; no I/O, no session state. */
68
+ export function decideToolCall(facts: ToolCallFacts): ToolDecision {
69
+ if (!facts.dryRun) {
70
+ return { kind: "run" };
71
+ }
72
+ if (resolveEffect(facts.effect, facts.input) === "read") {
73
+ return { kind: "run" };
74
+ }
75
+ return {
76
+ kind: "answer",
77
+ result: dryRunAnswer(facts.dryRunResult, facts.input),
78
+ };
79
+ }
80
+
81
+ function dryRunAnswer(
82
+ dryRunResult: ToolCallFacts["dryRunResult"],
83
+ input: unknown
84
+ ): ToolExecuteResult {
85
+ if (dryRunResult === undefined) {
86
+ return ACKNOWLEDGED;
87
+ }
88
+ if (typeof dryRunResult === "function") {
89
+ return dryRunResult(input as never);
90
+ }
91
+ return dryRunResult;
92
+ }
package/src/tools.ts CHANGED
@@ -59,6 +59,7 @@ import type {
59
59
  JsonSchemaObject,
60
60
  ToolContext,
61
61
  ToolDefinition,
62
+ ToolEffectDeclaration,
62
63
  ToolExecuteResult,
63
64
  ToolNeedsApproval,
64
65
  } from "./types.js";
@@ -70,6 +71,8 @@ export type {
70
71
  ToolConfig,
71
72
  ToolContext,
72
73
  ToolDefinition,
74
+ ToolEffect,
75
+ ToolEffectDeclaration,
73
76
  ToolExecuteResult,
74
77
  ToolExecution,
75
78
  ToolNeedsApproval,
@@ -91,6 +94,15 @@ type ServerToolConfig<TSchema> = {
91
94
  */
92
95
  outputSchema?: z.ZodTypeAny | JsonSchemaObject;
93
96
  needsApproval?: ToolNeedsApproval<ToolInput<TSchema>>;
97
+ /**
98
+ * Whether this tool changes anything outside the session. Undeclared means
99
+ * write, so a dry-run session answers it instead of running it.
100
+ */
101
+ effect?: ToolEffectDeclaration<ToolInput<TSchema>>;
102
+ /** What a `"write"` tool returns in a dry-run session. */
103
+ dryRunResult?:
104
+ | ToolExecuteResult
105
+ | ((input: ToolInput<TSchema>) => ToolExecuteResult);
94
106
  execute: (
95
107
  input: ToolInput<TSchema>,
96
108
  ctx: ToolContext
@@ -112,6 +124,13 @@ type AgentToolConfig<TSchema> = {
112
124
  execute?: never;
113
125
  /** Human-in-the-loop approvals are only supported for server tools. */
114
126
  needsApproval?: never;
127
+ /**
128
+ * Agent tools run inside the harness, not through the host's tool path, so
129
+ * a dry run cannot answer them. Classify the work as a server tool if it
130
+ * needs to be dry-runnable.
131
+ */
132
+ effect?: never;
133
+ dryRunResult?: never;
115
134
  };
116
135
 
117
136
  /**
package/src/types.ts CHANGED
@@ -867,6 +867,35 @@ export type ToolNeedsApproval<TInput> =
867
867
  | boolean
868
868
  | ((input: TInput) => boolean | Promise<boolean>);
869
869
 
870
+ /**
871
+ * Whether invoking a tool can change anything outside this session.
872
+ *
873
+ * `"read"` cannot, by construction — a query, a file read, a listing.
874
+ * `"write"` changes something a person or another agent will later see: an
875
+ * external system (a comment, a message, a ticket) or shared storage another
876
+ * session reads back.
877
+ *
878
+ * This is what {@link SendMessageOptions.dryRun} enforces. In a dry-run
879
+ * session a write is answered by the host and never executed, while reads run
880
+ * for real — so an agent can be run against real data to see what it *would*
881
+ * have done.
882
+ *
883
+ * A tool that declares nothing is treated as a write: a dry run promises
884
+ * "nothing outside this session changed", which it cannot promise about a tool
885
+ * it knows nothing about. An unannotated tool is therefore useless in a dry
886
+ * run.
887
+ */
888
+ export type ToolEffect = "read" | "write";
889
+
890
+ /**
891
+ * A tool's declared {@link ToolEffect} — fixed, or decided per call from
892
+ * validated input for tools that read or write depending on their arguments
893
+ * (an HTTP request keyed on method, a SQL tool keyed on statement).
894
+ */
895
+ export type ToolEffectDeclaration<TInput> =
896
+ | ToolEffect
897
+ | ((input: TInput) => ToolEffect);
898
+
870
899
  export interface ToolConfig<TInput> {
871
900
  description: string;
872
901
  /**
@@ -895,6 +924,20 @@ export interface ToolConfig<TInput> {
895
924
  * `execution: "server"` tools on the local runtime.
896
925
  */
897
926
  needsApproval?: ToolNeedsApproval<TInput>;
927
+ /**
928
+ * Whether this tool changes anything outside the session. Undeclared means
929
+ * write, so a dry run stubs it — see {@link ToolEffect}.
930
+ */
931
+ effect?: ToolEffectDeclaration<TInput>;
932
+ /**
933
+ * What a write tool returns in a dry-run session instead of executing.
934
+ *
935
+ * Omit for a tool whose result the caller only acknowledges. Supply one when
936
+ * the result is read structurally — a caller that uses `result.revision`
937
+ * breaks on a bare string — so a dry run stays on the same path production
938
+ * takes. Ignored unless {@link effect} resolves to `"write"`.
939
+ */
940
+ dryRunResult?: ToolExecuteResult | ((input: TInput) => ToolExecuteResult);
898
941
  /**
899
942
  * In-process implementation. Required when {@link execution} is
900
943
  * `"server"` (the default); ignored for `"agent"` tools.
@@ -918,6 +961,10 @@ export interface ToolDefinition extends BrandedDefinition<"tool"> {
918
961
  readonly inputSchema?: z.ZodTypeAny | JsonSchemaObject;
919
962
  readonly outputSchema?: z.ZodTypeAny | JsonSchemaObject;
920
963
  readonly needsApproval?: ToolNeedsApproval<never>;
964
+ readonly effect?: ToolEffectDeclaration<never>;
965
+ readonly dryRunResult?:
966
+ | ToolExecuteResult
967
+ | ((input: never) => ToolExecuteResult);
921
968
  readonly execute?: (
922
969
  input: never,
923
970
  ctx: ToolContext
@@ -1349,6 +1396,13 @@ export type SessionEventPayload =
1349
1396
  toolName: string;
1350
1397
  output?: unknown;
1351
1398
  isError: boolean;
1399
+ /**
1400
+ * The tool never ran: a write answered by the host in a `dryRun`
1401
+ * session. `actions.requested` still carries the arguments the model
1402
+ * chose, so this is the marker that separates "what it would have
1403
+ * done" from a real effect when reading a trajectory.
1404
+ */
1405
+ stubbed?: true;
1352
1406
  parentCallId?: string;
1353
1407
  };
1354
1408
  }
@@ -1451,6 +1505,8 @@ export interface SessionInfo {
1451
1505
  mode: SessionMode;
1452
1506
  /** Omit / `"live"` for production traffic; `"eval"` for eval harness sessions. */
1453
1507
  purpose?: SessionPurpose;
1508
+ /** True when write-classified tool calls are answered by the host. */
1509
+ dryRun?: boolean;
1454
1510
  auth: AuthContext | null;
1455
1511
  title?: string;
1456
1512
  /**
@@ -1483,6 +1539,8 @@ export interface SessionRecord {
1483
1539
  * Older records omit this (treated as `"live"`).
1484
1540
  */
1485
1541
  purpose?: SessionPurpose;
1542
+ /** Frozen at create from {@link SendMessageOptions.dryRun}. */
1543
+ dryRun?: boolean;
1486
1544
  /**
1487
1545
  * Denormalized sticky A/B arms (mirrors durable `ab.assigned` events) so
1488
1546
  * the agent loop / tools can read arms without re-folding the log. Source
@@ -1657,6 +1715,15 @@ export interface SendMessageOptions {
1657
1715
  * (`defineAB`) so regression runs do not mix into live metrics.
1658
1716
  */
1659
1717
  purpose?: SessionPurpose;
1718
+ /**
1719
+ * Answer every write-classified tool call from the host instead of running
1720
+ * it, for this session's whole life (create only — a follow-up cannot clear
1721
+ * it, so a session that starts as a rehearsal stays one).
1722
+ *
1723
+ * Reads execute, writes are answered and recorded, and the arguments the
1724
+ * model chose stay on the session log as the record of the intended action.
1725
+ */
1726
+ dryRun?: boolean;
1660
1727
  /**
1661
1728
  * Absolute directory used as the local harness `cwd` for this session.
1662
1729
  * On create, sets the session workspace. On resume (continuation hit),
@@ -2506,6 +2573,12 @@ export interface DiscoveredTool {
2506
2573
  * predicate). Runtime evaluation still runs the predicate per call.
2507
2574
  */
2508
2575
  needsApproval: boolean;
2576
+ /**
2577
+ * The tool's declared {@link ToolEffect} when it declares one statically, or
2578
+ * `"dynamic"` when it declares a predicate resolved per call. Absent when it
2579
+ * declares nothing, which a dry run treats as a write.
2580
+ */
2581
+ effect?: ToolEffect | "dynamic";
2509
2582
  /**
2510
2583
  * Agent tools: script body materialized under
2511
2584
  * `.agent-serve/tools/<name>.sh`.