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
38 changes: 35 additions & 3 deletions packages/client/src/promise/generated/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Effect, Schema } from "effect"
import { fromJson } from "./data.js"
import type { Extension } from "./extension.js"
import { executeProgram } from "./interpreter/execute.js"
import { extensionGlobals } from "./interpreter/extensions.js"
import { globalNames } from "./interpreter/globals.js"
import { scanTools } from "./scan.js"
import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Tools } from "./tools.js"

export { scanTools }

/** A tool call admitted during an execution. */
export type {
CallResult,
Expand Down Expand Up @@ -64,6 +68,8 @@ export type ExecuteOptions<
> = Options<Provided, Extensions> & {
/** Source for one program in the supported JavaScript subset. */
code: string
/** Read-only JSON values bound in the program's outer global scope (shadowable by top-level declarations). */
bindings?: Readonly<Record<string, DataValue>>
}

/** A JSON value that can cross the confined interpreter boundary. */
Expand Down Expand Up @@ -128,7 +134,7 @@ export type Result = typeof Result.Type
/** Reusable confined runtime over explicit tools. */
export type Runtime<R = never> = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly execute: (code: string) => Effect.Effect<Result, never, R>
readonly execute: (code: string, bindings?: Readonly<Record<string, DataValue>>) => Effect.Effect<Result, never, R>
}

const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => {
Expand All @@ -150,7 +156,7 @@ export const execute = <
const Extensions extends ReadonlyArray<Extension> = [],
>(
options: ExecuteOptions<Provided, Extensions>,
): Effect.Effect<Result, never, Environment<Provided, Extensions>> => make(options).execute(options.code)
): Effect.Effect<Result, never, Environment<Provided, Extensions>> => make(options).execute(options.code, options.bindings)

/** Creates an Effect-native runtime over explicit, schema-described tools and extensions. */
export const make = <
Expand All @@ -175,7 +181,10 @@ export const make = <
get catalog() {
return prepared.catalog
},
execute: (code) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
execute: (code, bindings) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => [
...Object.entries(bindings ?? {}).map(([name, value]) => [name, fromJson(ctx, value)] as const),
...extensionGlobals(ctx, extensions),
]),
}
}
2 changes: 1 addition & 1 deletion packages/codemode/src/interpreter/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const executeProgram = <R>(
})
}

const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => {
export const parseProgram = (code: string): { ok: true; program: Program } | { ok: false; error: Diagnostic } => {
try {
const program = parse(code, {
ecmaVersion: "latest",
Expand Down
249 changes: 249 additions & 0 deletions packages/codemode/src/scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { AnyNode, BlockStatement, ModuleDeclaration, Pattern, Program, Statement } from "acorn"
import { Result } from "effect"
import type { Diagnostic } from "./codemode.js"
import { parseProgram } from "./interpreter/execute.js"
import { formatLocation } from "./interpreter/model.js"

/**
* Extracts every `tools.<path>` referenced by a Code Mode program, in first-seen order. Fails on syntax errors,
* dynamic bracket access (`tools[name]`), or aliasing/passing `tools` rather than calling `tools.<path>(...)` directly.
*/
export function scanTools(code: string): Result.Result<ReadonlyArray<string>, Diagnostic> {
if (code.trim().length === 0) {
return Result.fail({ kind: "ParseError", message: "Code cannot be empty." })
}
const parsed = parseProgram(code)
if (!parsed.ok) return Result.fail(parsed.error)

const found = new Set<string>()
const scopes: Array<Set<string>> = [collectScope(parsed.program, true)]
const ancestors: Array<AnyNode> = []

const isShadowed = () => scopes.some((scope) => scope.has("tools"))

const fail = (node: AnyNode, message: string): Diagnostic => {
const loc = node.loc?.start ? { line: node.loc.start.line, column: node.loc.start.column + 1 } : undefined
return {
kind: "UnsupportedSyntax",
message: `${message}${loc ? formatLocation(loc) : ""}`,
...(loc ? { location: loc } : {}),
}
}

const visit = (node: AnyNode | null | undefined): Diagnostic | undefined => {
if (!node) return undefined

if (node.type === "Identifier" && node.name === "tools" && !isShadowed()) {
const parent = ancestors.at(-1)
if (!isReferencePosition(node, parent)) return undefined
const chain = readStaticToolCall(node, ancestors)
if (!chain.ok) return fail(chain.node, chain.reason)
found.add(chain.path)
return undefined
}

const pushedScope = enterNodeScope(node)
if (pushedScope) scopes.push(pushedScope)
ancestors.push(node)

const error = visitChildren(node, visit)

ancestors.pop()
if (pushedScope) scopes.pop()
return error
}

const diagnostic = visit(parsed.program)
if (diagnostic) return Result.fail(diagnostic)
return Result.succeed(Array.from(found))
}

function isReferencePosition(node: AnyNode, parent: AnyNode | undefined): boolean {
if (!parent) return true
if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false
if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return false
if (parent.type === "MethodDefinition" && parent.key === node && !parent.computed) return false
if (
(parent.type === "LabeledStatement" || parent.type === "BreakStatement" || parent.type === "ContinueStatement") &&
parent.label === node
) {
return false
}
if (
(parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ClassDeclaration" || parent.type === "ClassExpression") &&
parent.id === node
) {
return false
}
if (parent.type === "VariableDeclarator" && parent.id === node) return false
return true
}

function readStaticToolCall(
root: AnyNode,
ancestors: ReadonlyArray<AnyNode>,
): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly node: AnyNode; readonly reason: string } {
const segments: Array<string> = []
let current = root
let index = ancestors.length - 1

while (index >= 0) {
const parent = ancestors[index]!
if (parent.type === "ChainExpression" && parent.expression === current) {
current = parent
index -= 1
continue
}
if (parent.type !== "MemberExpression" || parent.object !== current) break
if (!parent.computed && parent.property.type === "Identifier") {
segments.push(parent.property.name)
current = parent
index -= 1
continue
}
if (parent.computed && parent.property.type === "Literal" && typeof parent.property.value === "string") {
segments.push(parent.property.value)
current = parent
index -= 1
continue
}
return {
ok: false,
node: parent.property,
reason: "Schedule checks must reference tools statically (e.g. tools.slack.read_thread), not with dynamic bracket expressions.",
}
}

const callParent = index >= 0 ? ancestors[index] : undefined
if (segments.length === 0 || callParent?.type !== "CallExpression" || callParent.callee !== current) {
return {
ok: false,
node: current,
reason: "Schedule checks must call tools directly as tools.<name>(...), not alias or pass the tools object.",
}
}
return { ok: true, path: segments.join(".") }
}

function enterNodeScope(node: AnyNode): Set<string> | undefined {
if (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
) {
const names = new Set<string>()
if (node.id) names.add(node.id.name)
for (const param of node.params) collectPattern(param, names)
if (node.body.type === "BlockStatement") {
for (const name of collectScope(node.body, true)) names.add(name)
}
return names
}
if (node.type === "BlockStatement") return collectScope(node, false)
if (node.type === "CatchClause") {
const names = new Set<string>()
if (node.param) collectPattern(node.param, names)
return names
}
if (node.type === "ForStatement" && node.init?.type === "VariableDeclaration" && node.init.kind !== "var") {
const names = new Set<string>()
for (const decl of node.init.declarations) collectPattern(decl.id, names)
return names
}
if (
(node.type === "ForInStatement" || node.type === "ForOfStatement") &&
node.left.type === "VariableDeclaration" &&
node.left.kind !== "var"
) {
const names = new Set<string>()
for (const decl of node.left.declarations) collectPattern(decl.id, names)
return names
}
return undefined
}

function collectScope(container: Program | BlockStatement, includeVar: boolean): Set<string> {
const names = new Set<string>()
for (const statement of container.body) {
if (statement.type === "FunctionDeclaration" && statement.id) names.add(statement.id.name)
if (statement.type === "ClassDeclaration" && statement.id) names.add(statement.id.name)
if (statement.type === "VariableDeclaration" && (includeVar || statement.kind !== "var")) {
for (const decl of statement.declarations) collectPattern(decl.id, names)
}
}
if (includeVar) collectNestedVars(container.body, names)
return names
}

function collectNestedVars(statements: ReadonlyArray<Statement | ModuleDeclaration>, names: Set<string>) {
const walkStmt = (node: AnyNode | null | undefined) => {
if (!node) return
if (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
) {
return
}
if (node.type === "VariableDeclaration" && node.kind === "var") {
for (const decl of node.declarations) collectPattern(decl.id, names)
}
visitChildren(node, (child) => {
walkStmt(child)
return undefined
})
}
for (const stmt of statements) walkStmt(stmt)
}

function collectPattern(pattern: Pattern, names: Set<string>) {
switch (pattern.type) {
case "Identifier":
names.add(pattern.name)
return
case "AssignmentPattern":
collectPattern(pattern.left, names)
return
case "RestElement":
collectPattern(pattern.argument, names)
return
case "ArrayPattern":
for (const element of pattern.elements) if (element) collectPattern(element, names)
return
case "ObjectPattern":
for (const prop of pattern.properties) {
if (prop.type === "RestElement") collectPattern(prop.argument, names)
if (prop.type === "Property") collectPattern(prop.value, names)
}
return
case "MemberExpression":
return
}
}

function visitChildren(
node: AnyNode,
visit: (child: AnyNode | null | undefined) => Diagnostic | undefined,
): Diagnostic | undefined {
for (const [key, value] of Object.entries(node as unknown as Record<string, unknown>)) {
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue
if (Array.isArray(value)) {
for (const item of value) {
if (isAstNode(item)) {
const error = visit(item)
if (error) return error
}
}
continue
}
if (isAstNode(value)) {
const error = visit(value)
if (error) return error
}
}
return undefined
}

function isAstNode(value: unknown): value is AnyNode {
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"
}
Loading
Loading