Require a timeout on every agent tool

This commit is contained in:
Ian Gulliver
2026-10-22 15:35:33 -07:00
parent c59e1cb4c3
commit b2cb15dd2a
12 changed files with 196 additions and 111 deletions
+47 -2
View File
@@ -30,6 +30,31 @@ struct Tool {
}
let stdoutLock = NSLock()
let maxTimeoutMs = 600_000
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func run(_ body: () -> Void) {
let first = lock.withLock {
defer { done = true }
return !done
}
if first {
body()
}
}
}
func withTimeout(_ schema: String) -> Any {
var s = try! JSONSerialization.jsonObject(with: Data(schema.utf8)) as! [String: Any]
var props = s["properties"] as? [String: Any] ?? [:]
props["timeout_ms"] = ["type": "integer", "description": "Give up on the call after this long (at most \(maxTimeoutMs))."]
s["properties"] = props
s["required"] = (s["required"] as? [String] ?? []) + ["timeout_ms"]
return s
}
let mediaDir: String? = {
let args = CommandLine.arguments
@@ -84,7 +109,7 @@ struct Server {
resp["result"] = [:] as [String: Any]
case "tools/list":
resp["result"] = ["tools": tools.map { t in
["name": t.name, "description": t.description, "inputSchema": try! JSONSerialization.jsonObject(with: Data(t.schema.utf8))]
["name": t.name, "description": t.description, "inputSchema": withTimeout(t.schema)]
}]
case "tools/call":
resp["result"] = await call(params)
@@ -104,8 +129,28 @@ struct Server {
guard let tool = tools.first(where: { $0.name == name }) else {
return ["content": [Content.text("unknown tool: \(name)").json], "isError": true]
}
guard let ms = args.int("timeout_ms"), ms > 0, ms <= maxTimeoutMs else {
return ["content": [Content.text("timeout_ms is required, between 1 and \(maxTimeoutMs)").json], "isError": true]
}
do {
let content = try await withDisplayAwake { try await tool.run(args) }
let content = try await withCheckedThrowingContinuation { (c: CheckedContinuation<[Content], Error>) in
let once = Once()
let work = Task {
do {
let content = try await withDisplayAwake { try await tool.run(args) }
once.run { c.resume(returning: content) }
} catch {
once.run { c.resume(throwing: error) }
}
}
Task {
try? await Task.sleep(for: .milliseconds(ms))
once.run {
work.cancel()
c.resume(throwing: ToolError("\(name) gave no result within \(ms)ms"))
}
}
}
return ["content": content.map { $0.json }]
} catch let e as ToolError {
return ["content": [Content.text(e.message).json], "isError": true]