@cursor/july 0.1.82 → 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 (126) hide show
  1. package/README.md +2 -2
  2. package/dist/docs/404.html +2 -2
  3. package/dist/docs/ab.html +3 -3
  4. package/dist/docs/assets/{app.BEHVaPKt.js → app.kqdC1N7p.js} +1 -1
  5. package/dist/docs/assets/chunks/@localSearchIndexroot.BqhvUA76.js +1 -0
  6. package/dist/docs/assets/chunks/{VPLocalSearchBox.D2-5PKih.js → VPLocalSearchBox.DXDE2WgG.js} +1 -1
  7. package/dist/docs/assets/chunks/{theme.uNqJ4MUO.js → theme.DFYqbyta.js} +2 -2
  8. package/dist/docs/assets/{guides_cloud-runtime.md.V5igN4Sq.js → guides_cloud-runtime.md.CbklWfzh.js} +1 -1
  9. package/dist/docs/assets/{guides_cloud-runtime.md.V5igN4Sq.lean.js → guides_cloud-runtime.md.CbklWfzh.lean.js} +1 -1
  10. package/dist/docs/assets/{reference_agent-config.md.BZnEKfEc.js → reference_agent-config.md.CTWp4DnU.js} +1 -1
  11. package/dist/docs/assets/{reference_skills.md.B_jHN7JL.js → reference_skills.md.BRF2nDv9.js} +2 -2
  12. package/dist/docs/assets/{reference_skills.md.B_jHN7JL.lean.js → reference_skills.md.BRF2nDv9.lean.js} +1 -1
  13. package/dist/docs/assets/troubleshooting.md.KgmmaCgw.js +1 -0
  14. package/dist/docs/building-with-agents.html +3 -3
  15. package/dist/docs/concepts.html +3 -3
  16. package/dist/docs/deployment.html +3 -3
  17. package/dist/docs/evals.html +3 -3
  18. package/dist/docs/example-agents/approval-buddy.html +3 -3
  19. package/dist/docs/example-agents/benny.html +3 -3
  20. package/dist/docs/example-agents/bugbot.html +3 -3
  21. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  22. package/dist/docs/example-agents/codeowners-review.html +3 -3
  23. package/dist/docs/example-agents/concierge.html +3 -3
  24. package/dist/docs/example-agents/fsd.html +3 -3
  25. package/dist/docs/example-agents/index.html +3 -3
  26. package/dist/docs/example-agents/knowledge-base.html +3 -3
  27. package/dist/docs/example-agents/oncall.html +3 -3
  28. package/dist/docs/example-agents/security-reviewer.html +3 -3
  29. package/dist/docs/example-agents/slack-agent.html +3 -3
  30. package/dist/docs/example-agents/weather-agent.html +3 -3
  31. package/dist/docs/guides/agent-to-agent.html +3 -3
  32. package/dist/docs/guides/cloud-runtime.html +5 -5
  33. package/dist/docs/guides/convert-automation.html +3 -3
  34. package/dist/docs/guides/github.html +3 -3
  35. package/dist/docs/guides/human-in-the-loop.html +3 -3
  36. package/dist/docs/guides/mcp-oauth.html +3 -3
  37. package/dist/docs/guides/opentelemetry.html +3 -3
  38. package/dist/docs/guides/slack.html +3 -3
  39. package/dist/docs/guides/webhooks.html +3 -3
  40. package/dist/docs/hashmap.json +1 -1
  41. package/dist/docs/hillclimbing.html +3 -3
  42. package/dist/docs/index.html +3 -3
  43. package/dist/docs/quickstart.html +3 -3
  44. package/dist/docs/reference/agent-config.html +5 -5
  45. package/dist/docs/reference/artifacts.html +3 -3
  46. package/dist/docs/reference/channels.html +3 -3
  47. package/dist/docs/reference/cli.html +3 -3
  48. package/dist/docs/reference/connections.html +3 -3
  49. package/dist/docs/reference/hooks.html +3 -3
  50. package/dist/docs/reference/http-api.html +3 -3
  51. package/dist/docs/reference/instructions.html +3 -3
  52. package/dist/docs/reference/playground.html +3 -3
  53. package/dist/docs/reference/project-layout.html +3 -3
  54. package/dist/docs/reference/prompt.html +3 -3
  55. package/dist/docs/reference/schedules.html +3 -3
  56. package/dist/docs/reference/sessions.html +3 -3
  57. package/dist/docs/reference/skills.html +5 -5
  58. package/dist/docs/reference/subagents.html +3 -3
  59. package/dist/docs/reference/tools.html +3 -3
  60. package/dist/docs/scaffolding-agents.html +3 -3
  61. package/dist/docs/storage.html +3 -3
  62. package/dist/docs/templates/agentic-owners.html +3 -3
  63. package/dist/docs/templates/demo.html +3 -3
  64. package/dist/docs/templates/pr-autofixer.html +3 -3
  65. package/dist/docs/templates/security-reviewer.html +3 -3
  66. package/dist/docs/templates/triage.html +3 -3
  67. package/dist/docs/troubleshooting.html +5 -5
  68. package/dist/files-backends/cursor-hosted.d.ts +6 -0
  69. package/dist/files-backends/cursor-hosted.d.ts.map +1 -1
  70. package/dist/files-backends/cursor-hosted.js +19 -0
  71. package/dist/files.d.ts +14 -1
  72. package/dist/files.d.ts.map +1 -1
  73. package/dist/files.js +14 -1
  74. package/dist/internal/discovery.d.ts.map +1 -1
  75. package/dist/internal/discovery.js +17 -2
  76. package/dist/internal/event-mapper.d.ts +7 -1
  77. package/dist/internal/event-mapper.d.ts.map +1 -1
  78. package/dist/internal/event-mapper.js +8 -8
  79. package/dist/internal/http-channel.d.ts.map +1 -1
  80. package/dist/internal/http-channel.js +1 -1
  81. package/dist/internal/mcp-endpoint.js +9 -0
  82. package/dist/internal/prompt-context.d.ts +4 -1
  83. package/dist/internal/prompt-context.d.ts.map +1 -1
  84. package/dist/internal/prompt-context.js +20 -2
  85. package/dist/internal/review-comments.js +15 -7
  86. package/dist/internal/sdk-runner.d.ts.map +1 -1
  87. package/dist/internal/sdk-runner.js +6 -9
  88. package/dist/internal/session-engine.d.ts +36 -2
  89. package/dist/internal/session-engine.d.ts.map +1 -1
  90. package/dist/internal/session-engine.js +143 -52
  91. package/dist/internal/skills-sync.d.ts +7 -5
  92. package/dist/internal/skills-sync.d.ts.map +1 -1
  93. package/dist/internal/skills-sync.js +9 -7
  94. package/dist/internal/tool-policy.d.ts +50 -0
  95. package/dist/internal/tool-policy.d.ts.map +1 -0
  96. package/dist/internal/tool-policy.js +51 -0
  97. package/dist/playground/assets/{index-CkuIrhg4.js → index-Cspgk9jC.js} +45 -45
  98. package/dist/playground/index.html +1 -1
  99. package/dist/tools.d.ts +16 -2
  100. package/dist/tools.d.ts.map +1 -1
  101. package/dist/types.d.ts +72 -0
  102. package/dist/types.d.ts.map +1 -1
  103. package/docs/guides/cloud-runtime.md +6 -1
  104. package/docs/reference/agent-config.md +6 -5
  105. package/docs/reference/skills.md +12 -3
  106. package/docs/troubleshooting.md +1 -1
  107. package/package.json +1 -1
  108. package/skills/framework-map/SKILL.md +2 -1
  109. package/src/files-backends/cursor-hosted.ts +21 -0
  110. package/src/files.ts +21 -2
  111. package/src/internal/discovery.ts +21 -1
  112. package/src/internal/event-mapper.ts +10 -1
  113. package/src/internal/http-channel.ts +1 -0
  114. package/src/internal/mcp-endpoint.ts +9 -0
  115. package/src/internal/prompt-context.ts +25 -2
  116. package/src/internal/review-comments.ts +17 -7
  117. package/src/internal/sdk-runner.ts +10 -11
  118. package/src/internal/session-engine.ts +174 -39
  119. package/src/internal/skills-sync.ts +10 -7
  120. package/src/internal/tool-policy.ts +92 -0
  121. package/src/tools.ts +19 -0
  122. package/src/types.ts +78 -0
  123. package/dist/docs/assets/chunks/@localSearchIndexroot.Vvio4stV.js +0 -1
  124. package/dist/docs/assets/troubleshooting.md.DQ-hYuZT.js +0 -1
  125. /package/dist/docs/assets/{reference_agent-config.md.BZnEKfEc.lean.js → reference_agent-config.md.CTWp4DnU.lean.js} +0 -0
  126. /package/dist/docs/assets/{troubleshooting.md.DQ-hYuZT.lean.js → troubleshooting.md.KgmmaCgw.lean.js} +0 -0
@@ -88,7 +88,15 @@ export class TurnEventMapper {
88
88
  /** Per-parent-call accumulators for nested subagent streams. */
89
89
  private readonly nested = new Map<string, NestedAccumulators>();
90
90
 
91
- constructor(private readonly emit: (payload: SessionEventPayload) => void) {}
91
+ /**
92
+ * @param isStubbed Reports whether a finished tool call was answered by the
93
+ * host rather than executed. The harness reports an answered call exactly
94
+ * like a real one, so the engine is the only place that knows.
95
+ */
96
+ constructor(
97
+ private readonly emit: (payload: SessionEventPayload) => void,
98
+ private readonly isStubbed: (callId: string) => boolean = () => false
99
+ ) {}
92
100
 
93
101
  /** Per-turn token usage summed across model steps. */
94
102
  get usage(): TurnUsage | undefined {
@@ -303,6 +311,7 @@ export class TurnEventMapper {
303
311
  toolName: description.toolName,
304
312
  output: result.output,
305
313
  isError: result.isError,
314
+ ...(this.isStubbed(callId) ? { stubbed: true as const } : {}),
306
315
  parentCallId,
307
316
  },
308
317
  });
@@ -109,6 +109,7 @@ export function buildHttpChannelRoutes(args: {
109
109
  ...(body.state === undefined ? {} : { state: body.state as JsonValue }),
110
110
  ...(typeof body.title === "string" ? { title: body.title } : {}),
111
111
  ...(body.purpose === "eval" ? { purpose: "eval" as const } : {}),
112
+ ...(body.dryRun === true ? { dryRun: true } : {}),
112
113
  ...(workspaceFiles === undefined ? {} : { workspaceFiles }),
113
114
  ...(cloud === undefined ? {} : { cloud }),
114
115
  });
@@ -312,6 +312,15 @@ function buildConnectionBridgeMcpServer(
312
312
  if (reason !== undefined) {
313
313
  return errorResult(reason);
314
314
  }
315
+ // The one tool path that never reaches `executeServerTool`, so it asks
316
+ // the policy itself.
317
+ const answer = engine.bridgedCallAnswer({
318
+ sessionId: sessionId ?? "",
319
+ toolName: request.params.name,
320
+ });
321
+ if (answer.answered) {
322
+ return sdkResultToMcpResult(answer.result);
323
+ }
315
324
  try {
316
325
  const result = await engine.host.mcp.callTool(
317
326
  connectionName,
@@ -39,17 +39,40 @@ export interface SessionContextInput {
39
39
  */
40
40
  channelKind?: string;
41
41
  environment: PromptEnvironment;
42
- /** Injectable clock for tests; defaults to now. */
42
+ /**
43
+ * Injectable clock for tests; defaults to the mocked prompt time when
44
+ * `CURSOR_SDK_MOCK_PROMPT_TIME` is set, then to now.
45
+ */
43
46
  now?: Date;
44
47
  }
45
48
 
49
+ /**
50
+ * Development-only override for the wall clock rendered into prompt content.
51
+ * Same variable the Cursor SDK harness reads for its prompt clock (the
52
+ * user-message <timestamp> prefix and user_info date), so one knob keeps the
53
+ * session context block and the harness-rendered clock consistent. Read on
54
+ * every render so a simulation harness can advance the instant between
55
+ * sessions. Never affects runtime clocks (schedules, reminders) or tools.
56
+ */
57
+ const MOCK_PROMPT_TIME_ENV = "CURSOR_SDK_MOCK_PROMPT_TIME";
58
+
59
+ function readMockPromptTimeFromEnv(): Date | undefined {
60
+ const raw = process.env[MOCK_PROMPT_TIME_ENV];
61
+ if (raw === undefined || raw === "") {
62
+ return undefined;
63
+ }
64
+ const parsed = new Date(raw);
65
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
66
+ }
67
+
46
68
  export function buildSessionContextBlock(input: SessionContextInput): string {
69
+ const now = input.now ?? readMockPromptTimeFromEnv() ?? new Date();
47
70
  const lines = [
48
71
  "<agentkit_context>",
49
72
  `session: ${input.sessionId}`,
50
73
  `channel: ${input.channelId}`,
51
74
  `environment: ${input.environment}`,
52
- `time: ${(input.now ?? new Date()).toISOString()}`,
75
+ `time: ${now.toISOString()}`,
53
76
  "</agentkit_context>",
54
77
  ];
55
78
  const presence = channelPresenceLine(input);
@@ -690,10 +690,13 @@ async function postReviewViaHostGithub(
690
690
  /**
691
691
  * Ids of the inline comments a just-created review owns.
692
692
  *
693
- * `pulls.createReview` returns the review, not its comments, so they come
694
- * from the review-scoped listing. Scoping to the review matters: the
695
- * PR-wide list is ordered oldest-first and runs to thousands on a busy PR,
696
- * so the comments we just created are the ones a page cap would drop.
693
+ * `pulls.createReview` returns the review, not its comments.
694
+ * `listCommentsForReview` either omits those rows or returns them with
695
+ * `line` / `original_line` unset, so the line filter drops every id, the
696
+ * store records no findings, and the next run cannot judge or resolve
697
+ * those threads. Read the PR-wide list instead and keep only this
698
+ * review's rows. Newest-first so a busy PR's older comments cannot push
699
+ * ours past the page cap.
697
700
  * Best-effort: any failure yields an empty list, which costs the next run
698
701
  * its prior findings rather than risking a wrong id. Never throws — the
699
702
  * review is already posted by this point.
@@ -714,22 +717,29 @@ async function readPostedComments(args: {
714
717
  const posted: PostedReviewComment[] = [];
715
718
  for (let page = 1; page <= REVIEW_COMMENTS_MAX_PAGES; page++) {
716
719
  args.signal?.throwIfAborted();
717
- const response = await args.octokit.rest.pulls.listCommentsForReview({
720
+ const response = await args.octokit.rest.pulls.listReviewComments({
718
721
  owner: args.owner,
719
722
  repo: args.repo,
720
723
  pull_number: args.prNumber,
721
- review_id: args.reviewId,
724
+ sort: "created",
725
+ direction: "desc",
722
726
  per_page: REVIEW_COMMENTS_PAGE_SIZE,
723
727
  page,
724
728
  });
725
729
  for (const comment of response.data) {
730
+ if (comment.pull_request_review_id !== args.reviewId) {
731
+ continue;
732
+ }
726
733
  const line = comment.original_line ?? comment.line;
727
734
  if (line === null || line === undefined) {
728
735
  continue;
729
736
  }
730
737
  posted.push({ id: comment.id, file: comment.path, line });
731
738
  }
732
- if (response.data.length < REVIEW_COMMENTS_PAGE_SIZE) {
739
+ if (
740
+ posted.length >= args.expected ||
741
+ response.data.length < REVIEW_COMMENTS_PAGE_SIZE
742
+ ) {
733
743
  break;
734
744
  }
735
745
  }
@@ -514,7 +514,7 @@ export class CursorSdkRunner implements AgentRunner {
514
514
  request: RunnerTurnRequest,
515
515
  shared: Pick<AgentOptions, "model" | "apiKey" | "mcpServers" | "agents">
516
516
  ): Promise<SDKAgent> {
517
- const cloud = toCloudAgentOptions(request.cloud);
517
+ const cloud = toCloudAgentOptions(request.cloud, request.agentServeAgent);
518
518
  const hasEnvVars =
519
519
  cloud.envVars !== undefined && Object.keys(cloud.envVars).length > 0;
520
520
  const options: AgentOptions = {
@@ -603,24 +603,23 @@ export function toModelSelection(model: ModelSetting): ModelSelection {
603
603
  }
604
604
 
605
605
  function toCloudAgentOptions(
606
- cloud: RunnerTurnRequest["cloud"]
606
+ cloud: RunnerTurnRequest["cloud"],
607
+ agentServeAgent?: string
607
608
  ): CloudAgentOptions {
608
- if (cloud === undefined) {
609
- return {};
610
- }
611
609
  return {
612
- ...(cloud.env === undefined ? {} : { env: cloud.env }),
613
- ...(cloud.repos === undefined ? {} : { repos: cloud.repos }),
614
- ...(cloud.workOnCurrentBranch === undefined
610
+ ...(cloud?.env === undefined ? {} : { env: cloud.env }),
611
+ ...(cloud?.repos === undefined ? {} : { repos: cloud.repos }),
612
+ ...(cloud?.workOnCurrentBranch === undefined
615
613
  ? {}
616
614
  : { workOnCurrentBranch: cloud.workOnCurrentBranch }),
617
- ...(cloud.autoCreatePR === undefined
615
+ ...(cloud?.autoCreatePR === undefined
618
616
  ? {}
619
617
  : { autoCreatePR: cloud.autoCreatePR }),
620
- ...(cloud.skipReviewerRequest === undefined
618
+ ...(cloud?.skipReviewerRequest === undefined
621
619
  ? {}
622
620
  : { skipReviewerRequest: cloud.skipReviewerRequest }),
623
- ...(cloud.envVars === undefined ? {} : { envVars: cloud.envVars }),
621
+ ...(cloud?.envVars === undefined ? {} : { envVars: cloud.envVars }),
622
+ ...(agentServeAgent === undefined ? {} : { agentServeAgent }),
624
623
  };
625
624
  }
626
625
 
@@ -24,9 +24,14 @@ import {
24
24
  SlackNudgeStore,
25
25
  } from "../channels/slack/nudge-store.js";
26
26
  import { resolveDedicatedSlackHostBinding } from "../channels/slack/slack-channel.js";
27
+ import {
28
+ AGENT_SERVE_USER_STORE_FILES_SOURCE_ID,
29
+ agentStoreKeys,
30
+ } from "../files.js";
27
31
  import {
28
32
  cursorHostedFiles,
29
33
  isCursorHostedFilesAvailable,
34
+ isCursorUserStoreFilesAvailable,
30
35
  } from "../files-backends/cursor-hosted.js";
31
36
  import {
32
37
  type AgentProject,
@@ -129,6 +134,7 @@ import { SessionStore } from "./session-store.js";
129
134
  import { syncSkillsToStore } from "./skills-sync.js";
130
135
  import { StorageCoordinator } from "./storage-coordinator.js";
131
136
  import type { FrameworkSessionStore } from "./storage-roles.js";
137
+ import { decideToolCall } from "./tool-policy.js";
132
138
  import { normalizeToolResult, toolCallErrorMessage } from "./tool-result.js";
133
139
  import {
134
140
  type EstimateTurnCostFn,
@@ -316,7 +322,7 @@ export class SessionEngine {
316
322
  >();
317
323
  /** Once-per-process cache for the A/B snapshot backfill (see abSnapshot). */
318
324
  private restoredAbSnapshot: Promise<ABSnapshot | undefined> | undefined;
319
- /** In-flight skills → deployment store sync (see ensureCloudSkillsSynced). */
325
+ /** In-flight skills → Agent Store sync (see ensureCloudSkillsSynced). */
320
326
  private cloudSkillsSync: Promise<boolean> | undefined;
321
327
 
322
328
  private readonly runner: AgentRunner;
@@ -328,6 +334,15 @@ export class SessionEngine {
328
334
  private readonly activeTurns = new Map<string, Promise<TurnOutcome>>();
329
335
  /** Context used by the cloud turn's HTTP MCP callbacks. */
330
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>>();
331
346
  /**
332
347
  * Delivered-nudge count per session at the last tool boundary. The carrier
333
348
  * drains out-of-process, so a growing count is how the engine learns a
@@ -877,6 +892,7 @@ export class SessionEngine {
877
892
  channelId,
878
893
  mode: sendOptions.mode ?? "chat",
879
894
  purpose,
895
+ ...(sendOptions.dryRun === true ? { dryRun: true } : {}),
880
896
  continuationKey: sendOptions.continuationToken ?? newContinuationKey(),
881
897
  auth,
882
898
  title: sendOptions.title,
@@ -1591,7 +1607,10 @@ export class SessionEngine {
1591
1607
  );
1592
1608
  }
1593
1609
  };
1594
- 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));
1595
1614
 
1596
1615
  // Take a turn slot before any work happens. When every slot is busy the
1597
1616
  // turn waits FIFO — durable `turn.queued` marks the wait — and a
@@ -1701,10 +1720,12 @@ export class SessionEngine {
1701
1720
  pendingImages
1702
1721
  );
1703
1722
  this.activeToolContexts.set(sessionId, { record, turnId, emit });
1723
+ this.stubbedCallIds.set(sessionId, stubbed);
1704
1724
  try {
1705
1725
  outcome = await this.runner.runTurn(request);
1706
1726
  } finally {
1707
1727
  this.activeToolContexts.delete(sessionId);
1728
+ this.stubbedCallIds.delete(sessionId);
1708
1729
  }
1709
1730
  } catch (error) {
1710
1731
  this.approvals.cancelSession(sessionId, "deny");
@@ -1796,7 +1817,8 @@ export class SessionEngine {
1796
1817
  }
1797
1818
 
1798
1819
  /**
1799
- * Sync authored skills onto the deployment store at `skills/<name>/…`
1820
+ * Sync authored skills onto the Agent Store at `skills/<name>/…` (hosted)
1821
+ * or `agent-serve/<agent>/skills/<name>/…` (personal-key local+cloud)
1800
1822
  * before the first cloud prompt, so `--agent-store-skills-dir` and the
1801
1823
  * first-turn S3 scan can discover them. False when the sync fails: the
1802
1824
  * turn then runs without store skills rather than pointing discovery at
@@ -1817,16 +1839,22 @@ export class SessionEngine {
1817
1839
  if (this.cloudSkillsSync !== undefined) {
1818
1840
  return this.cloudSkillsSync;
1819
1841
  }
1842
+ const target = this.cloudSkillsStore();
1843
+ if (target === undefined) {
1844
+ this.cloudSkillsSync = Promise.resolve(false);
1845
+ return this.cloudSkillsSync;
1846
+ }
1820
1847
  const skills = this.project.agent.skills;
1821
1848
  let sync = syncSkillsToStore({
1822
- sink: cursorHostedFiles(),
1849
+ sink: target.sink,
1823
1850
  agentName: this.project.name,
1824
1851
  skills,
1852
+ skillsPrefix: target.skillsPrefix,
1825
1853
  }).then(
1826
1854
  () => true,
1827
1855
  (error) => {
1828
1856
  this.logger(
1829
- `[skills] deployment store sync failed: ${error instanceof Error ? error.message : String(error)}`
1857
+ `[skills] ${target.label} sync failed: ${error instanceof Error ? error.message : String(error)}`
1830
1858
  );
1831
1859
  return false;
1832
1860
  }
@@ -1840,6 +1868,36 @@ export class SessionEngine {
1840
1868
  return sync;
1841
1869
  }
1842
1870
 
1871
+ /**
1872
+ * Hosted deployments own the deployment store. Local `serve`/`run` with
1873
+ * a personal API key writes into the USER store under `agent-serve/`.
1874
+ */
1875
+ private cloudSkillsStore():
1876
+ | {
1877
+ sink: ReturnType<typeof cursorHostedFiles>;
1878
+ skillsPrefix: string;
1879
+ label: string;
1880
+ }
1881
+ | undefined {
1882
+ if (isCursorHostedFilesAvailable()) {
1883
+ return {
1884
+ sink: cursorHostedFiles(),
1885
+ skillsPrefix: agentStoreKeys.skillsPrefix(),
1886
+ label: "deployment store",
1887
+ };
1888
+ }
1889
+ if (isCursorUserStoreFilesAvailable()) {
1890
+ return {
1891
+ sink: cursorHostedFiles({
1892
+ storeSourceId: AGENT_SERVE_USER_STORE_FILES_SOURCE_ID,
1893
+ }),
1894
+ skillsPrefix: agentStoreKeys.userSkillsPrefix(this.project.name),
1895
+ label: "user store",
1896
+ };
1897
+ }
1898
+ return undefined;
1899
+ }
1900
+
1843
1901
  private async buildTurnRequest(
1844
1902
  record: SessionRecord,
1845
1903
  agent: ResolvedAgent,
@@ -1917,10 +1975,11 @@ export class SessionEngine {
1917
1975
  : SERVER_TOOLS_MCP_SERVER_NAME,
1918
1976
  });
1919
1977
  const hosted = isCursorHostedFilesAvailable();
1920
- // Synced whenever hosted, not just when skills exist: removing the
1921
- // last skill has to delete its store copies too. Discovery is native
1922
- // (`--agent-store-skills-dir`); this only has to land the bytes.
1923
- if (hosted) {
1978
+ // Synced whenever a store is reachable, not just when skills exist:
1979
+ // removing the last skill has to delete its store copies too.
1980
+ // Discovery is native (`--agent-store-skills-dir`); this only has
1981
+ // to land the bytes.
1982
+ if (hosted || isCursorUserStoreFilesAvailable()) {
1924
1983
  await this.ensureCloudSkillsSynced();
1925
1984
  }
1926
1985
  const memorySection = hosted
@@ -1954,6 +2013,9 @@ export class SessionEngine {
1954
2013
  ...(agent.model === undefined ? {} : { model: agent.model }),
1955
2014
  runtime,
1956
2015
  ...(mergedCloud === undefined ? {} : { cloud: mergedCloud }),
2016
+ ...(runtime === "cloud" && !isCursorHostedFilesAvailable()
2017
+ ? { agentServeAgent: this.project.name }
2018
+ : {}),
1957
2019
  customTools:
1958
2020
  runtime === "local"
1959
2021
  ? await this.buildCustomTools(record, agent, turnId, emit)
@@ -2388,6 +2450,21 @@ export class SessionEngine {
2388
2450
  emit: args.emit,
2389
2451
  };
2390
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
+ }
2391
2468
  const needsGate = await evaluateNeedsApproval(
2392
2469
  args.tool.definition.needsApproval,
2393
2470
  validation.value
@@ -2670,10 +2747,21 @@ export class SessionEngine {
2670
2747
  });
2671
2748
  }
2672
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
+
2673
2760
  const started = Date.now();
2674
2761
  this.logger(`[session] tool ${toolName} start (direct ${callId})`);
2675
2762
  let result: unknown;
2676
2763
  let isError: boolean;
2764
+ const stubbed = decision.kind === "answer";
2677
2765
  const live = record;
2678
2766
  const emit =
2679
2767
  live === undefined
@@ -2685,35 +2773,43 @@ export class SessionEngine {
2685
2773
  }).catch(() => {});
2686
2774
  };
2687
2775
  try {
2688
- result = normalizeToolResult(
2689
- await execute(validation.value as never, {
2690
- toolCallId: callId,
2691
- session,
2692
- workspaceDir,
2693
- stateRoot: this.stateRoot,
2694
- host: this.hostForSession(record?.sessionId),
2695
- // Scratch calls have no real session; their facade stays unbound.
2696
- artifacts:
2697
- record === undefined
2698
- ? this.artifactsApi()
2699
- : this.artifactsApi({
2700
- sessionId: record.sessionId,
2701
- turnId: callId,
2702
- }),
2703
- send: (channelId, message, sendOptions = {}) =>
2704
- this.send(channelId, message, {
2705
- ...sendOptions,
2706
- auth:
2707
- sendOptions.auth !== undefined
2708
- ? sendOptions.auth
2709
- : session.auth,
2710
- }),
2711
- getSession: (channelId, sessionId) =>
2712
- this.getChannelSession(channelId, sessionId),
2713
- emit,
2714
- })
2715
- );
2716
- 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
+ }
2717
2813
  } catch (error) {
2718
2814
  this.logger(
2719
2815
  `[session] tool ${toolName} failed (direct ${callId}): ${describeError(error)}`
@@ -2743,7 +2839,13 @@ export class SessionEngine {
2743
2839
  await this.appendEvent(record.sessionId, {
2744
2840
  type: "action.result",
2745
2841
  turnId: callId,
2746
- data: { callId, toolName, output: result, isError },
2842
+ data: {
2843
+ callId,
2844
+ toolName,
2845
+ output: result,
2846
+ isError,
2847
+ ...(stubbed ? { stubbed: true as const } : {}),
2848
+ },
2747
2849
  });
2748
2850
  }
2749
2851
 
@@ -2759,6 +2861,38 @@ export class SessionEngine {
2759
2861
  };
2760
2862
  }
2761
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
+
2762
2896
  // ==========================================================================
2763
2897
  // Events: append, dispatch to channel handlers + hooks
2764
2898
  // ==========================================================================
@@ -3399,6 +3533,7 @@ export class SessionEngine {
3399
3533
  channelId: record.channelId,
3400
3534
  mode: record.mode,
3401
3535
  purpose: record.purpose === "eval" ? "eval" : "live",
3536
+ ...(record.dryRun === true ? { dryRun: true } : {}),
3402
3537
  auth: record.auth,
3403
3538
  continuationKey: record.continuationKey,
3404
3539
  ...(record.abs === undefined ? {} : { abs: record.abs }),
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Sync authored skills into the deployment's Agent Store so cloud-runtime
3
- * turns discover them natively. The engine writes through the hosted sink
4
- * at store-root `skills/<name>/…`; the cloud VM is launched with
5
- * `--agent-store-skills-dir /cursor/stores/deployment/skills` and walks that
6
- * directory the same way it walks a user store.
2
+ * Sync authored skills onto an Agent Store so cloud-runtime turns discover
3
+ * them natively. Hosted deployments write store-root `skills/<name>/…` (the
4
+ * VM's `--agent-store-skills-dir /cursor/stores/deployment/skills`). Local
5
+ * `serve`/`run` with `runtime: "cloud"` writes
6
+ * `agent-serve/<agent>/skills/<name>/…` on the USER store.
7
7
  */
8
8
 
9
9
  import { readFile } from "node:fs/promises";
@@ -32,11 +32,14 @@ export async function syncSkillsToStore(args: {
32
32
  /** Sweeps leftover keys from the pre-native `agentkit/v1/{agent}/skills/` prefix. */
33
33
  agentName: string;
34
34
  skills: readonly DiscoveredSkill[];
35
+ /** Store-root directory that holds SKILL.md files. Defaults to `skills/`. */
36
+ skillsPrefix?: string;
35
37
  }): Promise<void> {
38
+ const skillsPrefix = args.skillsPrefix ?? agentStoreKeys.skillsPrefix();
36
39
  const desired = new Map<string, Uint8Array>();
37
40
  for (const skill of args.skills) {
38
41
  const key = (relPath: string): string =>
39
- agentStoreKeys.skillFile(skill.name, relPath);
42
+ agentStoreKeys.skillFile(skill.name, relPath, skillsPrefix);
40
43
  desired.set(key("SKILL.md"), Buffer.from(buildSkillMd(skill), "utf8"));
41
44
  for (const [relPath, source] of Object.entries(skill.files)) {
42
45
  desired.set(key(storeRelPath(relPath)), await sourceBytes(source));
@@ -46,7 +49,7 @@ export async function syncSkillsToStore(args: {
46
49
  [...desired].map(([key, body]) => putLastWriterWins(args.sink, key, body))
47
50
  );
48
51
  const [existing, leftover] = await Promise.all([
49
- args.sink.list(agentStoreKeys.skillsPrefix()),
52
+ args.sink.list(skillsPrefix),
50
53
  args.sink.list(legacySkillsPrefix(args.agentName)),
51
54
  ]);
52
55
  await Promise.all(
@@ -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
+ }