Browse Source

feat(core): improve shell tool guidance (#39401)

Aiden Cline 3 weeks ago
parent
commit
f95d04fea0

+ 10 - 3
packages/core/src/shell.ts

@@ -44,6 +44,7 @@ type Active = {
  * here; callers (e.g. `ShellTool`) own that association and store the shell ID.
  * here; callers (e.g. `ShellTool`) own that association and store the shell ID.
  */
  */
 export interface Interface {
 export interface Interface {
+  readonly name: () => Effect.Effect<string>
   readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
   readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
   // Currently running commands only; exited shells are retained for get/output but excluded here.
   // Currently running commands only; exited shells are retained for get/output but excluded here.
   readonly list: () => Effect.Effect<Shell.Info[]>
   readonly list: () => Effect.Effect<Shell.Info[]>
@@ -134,6 +135,13 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
       return session.info
       return session.info
     })
     })
 
 
+    const resolve = () =>
+      config
+        .entries()
+        .pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
+
+    const name = () => resolve().pipe(Effect.map(ShellSelect.name))
+
     const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
     const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
       const session = yield* require(id)
       const session = yield* require(id)
       const cursor = input?.cursor ?? 0
       const cursor = input?.cursor ?? 0
@@ -167,8 +175,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
     const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
     const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
       const id = Shell.ID.ascending()
       const id = Shell.ID.ascending()
       const cwd = input.cwd ?? location.directory
       const cwd = input.cwd ?? location.directory
-      const configShell = Config.latest(yield* config.entries(), "shell")
-      const shell = ShellSelect.preferred(configShell, options)
+      const shell = yield* resolve()
       const args = ShellSelect.args(shell, input.command)
       const args = ShellSelect.args(shell, input.command)
       const file = path.join(outputDir, `${id}.out`)
       const file = path.join(outputDir, `${id}.out`)
       const env = {
       const env = {
@@ -312,7 +319,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
       return session.info
       return session.info
     })
     })
 
 
-    return Service.of({ create, list, get, wait, timeout, output, remove })
+    return Service.of({ name, create, list, get, wait, timeout, output, remove })
   }),
   }),
 )
 )
 
 

+ 33 - 9
packages/core/src/tool/plugin/shell.ts

@@ -15,22 +15,40 @@ import { Shell } from "../../shell"
 
 
 export const name = "shell"
 export const name = "shell"
 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
-export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
 export const MAX_CAPTURE_BYTES = 1024 * 1024
 export const MAX_CAPTURE_BYTES = 1024 * 1024
 
 
 const BACKGROUND_STARTED = "The command was moved to the background."
 const BACKGROUND_STARTED = "The command was moved to the background."
 const BACKGROUND_INSTRUCTION =
 const BACKGROUND_INSTRUCTION =
   "You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
   "You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
+const OS =
+  process.platform === "darwin"
+    ? "macOS"
+    : process.platform === "win32"
+      ? "Windows"
+      : process.platform === "linux"
+        ? "Linux"
+        : process.platform
+const description = (shell?: string) =>
+  [
+    "Execute a shell command and return its output.",
+    ...(shell ? [`Commands run on ${OS} using ${shell}.`] : []),
+    "Quote file paths containing spaces or special characters.",
+    "Prefer dedicated tools over shell commands when possible.",
+    "When output is large, the full result is saved to a file and a truncated preview is returned.",
+    "Rely on automatic truncation unless filtering the output is more useful.",
+    "Commands accept an optional timeout, background commands have no timeout by default.",
+    "Background commands return immediately, and you will be notified when they complete.",
+  ].join(" ")
 
 
 export const Input = Schema.Struct({
 export const Input = Schema.Struct({
   command: Schema.String.annotate({ description: "Shell command string to execute" }),
   command: Schema.String.annotate({ description: "Shell command string to execute" }),
   workdir: Schema.optionalKey(Schema.String).annotate({
   workdir: Schema.optionalKey(Schema.String).annotate({
-    description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
+    description:
+      "Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead.",
+  }),
+  timeout: Schema.optionalKey(NonNegativeInt).annotate({
+    description: `Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to ${DEFAULT_TIMEOUT_MS} for foreground commands. Background commands have no timeout by default.`,
   }),
   }),
-  timeout: Schema.optionalKey(NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)))
-    .annotate({
-      description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
-    }),
   background: Schema.optionalKey(Schema.Boolean).annotate({
   background: Schema.optionalKey(Schema.Boolean).annotate({
     description:
     description:
       "Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
       "Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
@@ -69,13 +87,11 @@ const modelOutput = (output: Output): string | undefined => {
 // TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
 // TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
 // TODO: Port BashArity reusable command-prefix approvals.
 // TODO: Port BashArity reusable command-prefix approvals.
 // TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
 // TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
-// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
 // TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
 // TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
 // TODO: Persist job status and define restart recovery before exposing remote observation.
 // TODO: Persist job status and define restart recovery before exposing remote observation.
 // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
 // TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
 // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
 // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
 // TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
 // TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
-// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
 
 
 const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
 const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
 const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
 const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
@@ -144,7 +160,7 @@ export const Plugin = {
           ({
           ({
             name,
             name,
             options: { codemode: false },
             options: { codemode: false },
-            description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
+            description: description(),
             input: Input,
             input: Input,
             output: Output,
             output: Output,
             execute: (input, context) =>
             execute: (input, context) =>
@@ -291,5 +307,13 @@ export const Plugin = {
         ),
         ),
       )
       )
       .pipe(Effect.orDie)
       .pipe(Effect.orDie)
+
+    yield* ctx.session.hook("context", (event) =>
+      Effect.gen(function* () {
+        const tool = event.tools[name]
+        if (!tool) return
+        tool.description = description(yield* shell.name())
+      }),
+    )
   }),
   }),
 }
 }

+ 4 - 3
packages/core/test/tool-shell.test.ts

@@ -200,10 +200,11 @@ describe("ShellTool", () => {
         return withSession(tmp.path, (registry) =>
         return withSession(tmp.path, (registry) =>
           Effect.gen(function* () {
           Effect.gen(function* () {
             const definitions = yield* toolDefinitions(registry)
             const definitions = yield* toolDefinitions(registry)
-            const shell = definitions.find((tool) => tool.name === "shell")
-            expect(shell).toBeDefined()
+            const definition = definitions.find((tool) => tool.name === "shell")
+            expect(definition?.description).toStartWith("Execute a shell command and return its output.")
+            expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
             // Code Mode receives the declared output schema, including the command output text.
             // Code Mode receives the declared output schema, including the command output text.
-            expect(shell?.outputSchema).toHaveProperty("properties.output")
+            expect(definition?.outputSchema).toHaveProperty("properties.output")
             expect(
             expect(
               (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
               (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
                 (tool) => tool.name,
                 (tool) => tool.name,