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
5 changes: 5 additions & 0 deletions .changeset/safe-agent-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"durable-harness": patch
---

Discover exported agents from source syntax without importing scripts or code samples into the generated flow route.
9 changes: 9 additions & 0 deletions packages/durable-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ const nextConfig: NextConfig = {
export default withDurableHarness(nextConfig);
```

`withDurableHarness` discovers modules that export agents created at module
scope with `durableAgent`, `claudeCode`, `codex`, `opencode` or `pi`, and modules
that register a pi-durable opener with `resumeWith`. A script that creates an
agent for itself, an exported factory that creates one later, and code samples
in strings or comments are excluded. Import aliases, default exports, export
lists, and containers or accessors for an existing agent are supported.
For custom factories or effect-only agent registration, list the modules
explicitly: `withDurableHarness(nextConfig, { agents: ['lib/agent.ts'] })`.

Choose your harness and framework in the
[agent harnesses docs](https://vercel.com/docs/agent-harnesses).

Expand Down
3 changes: 3 additions & 0 deletions packages/durable-harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@
"check-dist": "node scripts/check-dist.mjs"
},
"dependencies": {
"@babel/parser": "^7.29.9",
"@babel/traverse": "^7.29.10",
"@opentelemetry/api": "1.9.0",
"@vercel/oidc": "3.2.0",
"@workflow/utils": "^5.0.2",
Expand Down Expand Up @@ -229,6 +231,7 @@
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"@types/babel__traverse": "^7.28.0",
"@vercel/agent-runs": "0.0.1",
"effect": "4.0.0-rc.112",
"harness-world": "workspace:*",
Expand Down
139 changes: 139 additions & 0 deletions packages/durable-harness/src/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { parse } from '@babel/parser';
import traverseModule, { type Binding, type NodePath } from '@babel/traverse';

// Babel's CommonJS entry has a default property when loaded by Node's ESM
// loader; bundlers may unwrap it already.
const traverse = 'default' in traverseModule ? traverseModule.default as typeof traverseModule : traverseModule;

type Reference = { source: string; name?: string };
export interface HarnessModule {
agent: boolean;
/** A real factory call, even without an export: classifies explicitly listed modules. */
agentFactory: boolean;
piOpener: boolean;
piSessions: boolean;
}

const FACTORIES: Readonly<Record<string, string>> = {
'durable-harness': 'durableAgent',
'durable-harness/claude-code': 'claudeCode',
'durable-harness/codex': 'codex',
'durable-harness/opencode': 'opencode',
'durable-harness/pi': 'pi',
};
const PI = 'durable-harness/pi-durable';

/**
* Read actual imports, calls and exports without loading the app. Automatic
* discovery follows exported values back to agents made at module scope;
* scripts and exported factories are not registration modules. Explicit
* `agents` handles factories or registrations this static check cannot see.
* Pi's resumeWith is deliberately different: it registers an opener for its
* side effect, even when its return value is not exported.
*/
export function harnessModule(text: string): HarnessModule {
const found: HarnessModule = { agent: false, agentFactory: false, piOpener: false, piSessions: false };
if (!text.includes('durable-harness')) return found;
let ast: ReturnType<typeof parse>;
const options = { sourceType: 'unambiguous', allowUndeclaredExports: true, createImportExpressions: true } as const;
try {
ast = parse(text, { ...options, plugins: ['typescript', 'jsx', 'decorators-legacy'] });
} catch {
// A .ts angle-bracket assertion or generic arrow may be ambiguous with
// JSX. No source extension is needed to handle both kinds of module.
try {
ast = parse(text, { ...options, plugins: ['typescript', 'decorators-legacy'] });
} catch {
return found;
}
}
traverse(ast, {
'CallExpression|OptionalCallExpression'(path) {
const callee = path.get('callee') as NodePath;
const imported = reference(callee);
if (!imported) return;
if (imported.source === PI) {
if (['openWorldStorage', 'acquireHarness', 'chatRoute'].includes(imported.name ?? '')) found.piSessions = true;
if (imported.name === 'resumeWith' && atModuleScope(path)) found.piOpener = found.agent = true;
} else if (FACTORIES[imported.source] === imported.name && imported.name) {
found.agentFactory = true;
if (atModuleScope(path) && reachesExport(path)) found.agent = true;
}
},
});
return found;
}

/** Static class initializers run at import; method bodies and instance fields do not. */
function atModuleScope(path: NodePath): boolean {
return !path.findParent((parent) => parent.isFunction() || (
(parent.isClassProperty() || parent.isClassPrivateProperty() || parent.isClassAccessorProperty()) && !parent.node.static
));
}

/** Follow a value import, namespace member, or local alias to its module. */
function reference(path: NodePath, seen = new Set<Binding>()): Reference | undefined {
if (path.isAwaitExpression()) return reference(path.get('argument'), seen);
if (path.isTSAsExpression() || path.isTSSatisfiesExpression() || path.isTSNonNullExpression() || path.isTSTypeAssertion() || path.isParenthesizedExpression()) return reference(path.get('expression') as NodePath, seen);
if (path.isImportExpression() && path.node.source.type === 'StringLiteral') return { source: path.node.source.value };
if (path.isCallExpression() && path.node.callee.type === 'Identifier' && path.node.callee.name === 'require' && !path.scope.getBinding('require') && path.node.arguments[0]?.type === 'StringLiteral') return { source: path.node.arguments[0].value };
if (path.isMemberExpression() || path.isOptionalMemberExpression()) {
const object = reference(path.get('object') as NodePath, seen);
const property = path.node.property;
const name = !path.node.computed && property.type === 'Identifier' ? property.name : property.type === 'StringLiteral' ? property.value : undefined;
return object && !object.name && name ? { source: object.source, name } : undefined;
}
if (!path.isIdentifier()) return undefined;
const binding = path.scope.getBinding(path.node.name);
if (!binding || seen.has(binding) || !binding.constant) return undefined;
seen.add(binding);
const declared = binding.path;
if (declared.isImportSpecifier() || declared.isImportNamespaceSpecifier()) {
const statement = declared.parentPath;
if (!statement.isImportDeclaration() || statement.node.importKind === 'type') return undefined;
if (declared.isImportNamespaceSpecifier()) return { source: statement.node.source.value };
if (declared.node.importKind === 'type') return undefined;
const imported = declared.node.imported;
return { source: statement.node.source.value, name: imported.type === 'Identifier' ? imported.name : imported.value };
}
if (!declared.isVariableDeclarator() || !declared.node.init) return undefined;
const imported = reference(declared.get('init') as NodePath, seen);
if (declared.node.id.type === 'Identifier') return imported;
if (declared.node.id.type !== 'ObjectPattern' || !imported || imported.name) return undefined;
for (const property of declared.node.id.properties) {
if (property.type !== 'ObjectProperty' || property.value.type !== 'Identifier' || property.value.name !== path.node.name) continue;
const key = property.key;
const name = !property.computed && key.type === 'Identifier' ? key.name : key.type === 'StringLiteral' ? key.value : undefined;
if (name) return { source: imported.source, name };
}
return undefined;
}

/** Does this value reach a value export, possibly through local bindings? */
function reachesExport(path: NodePath, seen = new Set<Binding>()): boolean {
for (let parent: NodePath | null = path; parent && !parent.isProgram(); parent = parent.parentPath) {
if (parent.isExportNamedDeclaration()) return parent.node.exportKind !== 'type';
if (parent.isExportDefaultDeclaration()) return true;
if (parent.isExportSpecifier() && parent.node.exportKind === 'type') return false;
if (parent.node.type.startsWith('TS') && !parent.isTSAsExpression() && !parent.isTSSatisfiesExpression() && !parent.isTSNonNullExpression() && !parent.isTSTypeAssertion()) return false;
if (parent.isAssignmentExpression()) {
const left = parent.get('left');
if (left.isMemberExpression()) {
const { object, property, computed } = left.node;
const key = !computed && property.type === 'Identifier' ? property.name : property.type === 'StringLiteral' ? property.value : undefined;
if (object.type === 'Identifier' && object.name === 'module' && key === 'exports' && !left.scope.getBinding('module')) return true;
if (object.type === 'Identifier' && object.name === 'exports' && !left.scope.getBinding('exports')) return true;
if (object.type === 'MemberExpression' && object.object.type === 'Identifier' && object.object.name === 'module' && !left.scope.getBinding('module') && !object.computed && object.property.type === 'Identifier' && object.property.name === 'exports') return true;
}
}
if (!parent.isVariableDeclarator() && !parent.isFunctionDeclaration() && !parent.isClassDeclaration() && !parent.isAssignmentExpression()) continue;
const identifiers = parent.isAssignmentExpression() ? parent.get('left').getBindingIdentifiers() : parent.getBindingIdentifiers();
for (const name of Object.keys(identifiers)) {
const binding = parent.scope.getBinding(name);
if (!binding || !binding.scope.path.isProgram() || seen.has(binding)) continue;
seen.add(binding);
if (binding.referencePaths.some((ref) => reachesExport(ref, seen))) return true;
}
}
return false;
}
12 changes: 8 additions & 4 deletions packages/durable-harness/src/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ export interface DurableHarnessOptions {
/**
* The modules that make your agents, relative to the project: durable-harness
* agents (`claudeCode()`, `pi()`, ...) and fx agents (libfx's
* `createFxAgent()`). Default: every source file that imports a harness
* subpath of durable-harness (for `durable-harness/pi-durable`, one that
* calls `resumeWith(`: it registers the opener a wake reopens sessions
* with) or calls `durableAgent(`, and every one that
* `createFxAgent()`). Default: modules that export an agent made at module
* scope with `durableAgent`, `claudeCode`, `codex`, `opencode` or `pi` from
* durable-harness (including import aliases and exported containers or
* accessors for that value). Calls inside factories, strings and comments
* do not count. A module that calls pi-durable's `resumeWith` at module
* scope is also included: it registers an opener, with or without an
* export. Use `agents` for custom factories or effect-only registrations.
* Automatic discovery also includes every module that
* imports `createFxAgent` (from `libfx`, or a module of yours that
* re-exports it) and calls it. A listed module that calls `createFxAgent(`
* is an fx agent's (the first one listed is the one the fx route mounts);
Expand Down
25 changes: 12 additions & 13 deletions packages/durable-harness/src/scan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { harnessModule } from './discovery.ts';
import { packageDir, resolves } from './native.ts';
import { FLOW_MAX_DURATION, FLOW_ROUTE, flowTrigger, FX_ROUTE, fxTrigger, type GeneratedRoute, LIBFX_QUEUE_PREFIX, WORKFLOW_FLOW_ROUTE } from './routing.ts';

Expand All @@ -22,7 +23,7 @@ export function appDir(projectDir: string): string | undefined {

export interface Source {
file: string;
/** It imports a harness subpath (it makes an agent), or `durable-harness/pi-durable` and calls `resumeWith(` (it registers an opener). */
/** It exports an agent made at module scope, or registers a pi-durable opener with `resumeWith(` there. */
agent: boolean;
/**
* It opens pi-durable sessions on the World (`openWorldStorage(`,
Expand Down Expand Up @@ -308,9 +309,6 @@ function matchesKey(pattern: string, file: string): boolean {
const SKIP = new Set(['node_modules', 'public', 'out', 'dist', 'build', 'coverage']);
const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
const ROUTE_FILE = /^route\.(?:[cm]?[jt]sx?)$/;
const CORE_IMPORT = /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness['"]/;
const HARNESS_IMPORT = /^(?!\s*import\s+type\b).*(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness\/(?:claude-code|codex|opencode|pi)['"]/m;
const PI_DURABLE_IMPORT = /^(?!\s*import\s+type\b).*(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]durable-harness\/pi-durable['"]/m;
const LIBFX = String.raw`['"]libfx(?:\/node)?['"]`;
const FX_CALL = /(?<!\bfunction\s*\*?\s*)\bcreateFxAgent\s*(?:<[^()]*>\s*)?\(/;

Expand Down Expand Up @@ -354,24 +352,26 @@ export const isFxModule = (text: string) => (importsLibfx(text) || importsCreate
* imports it from), both, or neither (an agent made through a function of
* the app's own).
*/
const listedKind = (text: string) => ({ harness: isHarnessModule(text), fx: fxCalls(text) > 0 });
const listedKind = (text: string) => {
const module = harnessModule(text);
return { harness: module.agentFactory || module.piOpener, fx: fxCalls(text) > 0 };
};

/**
* Whether a module registers a pi-durable opener: a value import of
* `durable-harness/pi-durable` and a `resumeWith(` call. A module that only
* opens storages there (a route) is not one: the flow route never imports it.
*/
export const isPiOpenerModule = (text: string) => PI_DURABLE_IMPORT.test(text) && /\bresumeWith\s*\(/.test(code(text));
export const isPiOpenerModule = (text: string) => harnessModule(text).piOpener;

/** Whether a module opens pi-durable sessions on the World: a value import of `durable-harness/pi-durable` and a call that opens one. */
export const opensPiSessions = (text: string) => PI_DURABLE_IMPORT.test(text) && /\b(?:openWorldStorage|acquireHarness|chatRoute)\s*\(/.test(code(text));
export const opensPiSessions = (text: string) => harnessModule(text).piSessions;

/**
* Whether a module makes a durable-harness agent: a value import of a harness
* subpath, `durableAgent(` from durable-harness, or a pi-durable opener
* (`resumeWith(`).
* Whether a module exports a durable-harness agent made at module scope, or
* registers a pi-durable opener (`resumeWith(`) there.
*/
export const isHarnessModule = (text: string) => HARNESS_IMPORT.test(text) || isPiOpenerModule(text) || (CORE_IMPORT.test(text) && /\bdurableAgent\s*\(/.test(text));
export const isHarnessModule = (text: string) => harnessModule(text).agent;

/** A function, not a value made now: an initializer an export would hold. */
const FUNCTION_VALUE = /^(?:async\s+)?(?:function\b|class\b|\([^)]*\)\s*(?::[^=]*)?=>|[\w$]+\s*=>)/;
Expand Down Expand Up @@ -493,11 +493,10 @@ function scan(projectDir: string, app: string): Source[] {
}
const inApp = full.startsWith(app + sep);
const route = inApp && ROUTE_FILE.test(entry.name) ? routeOf(app, full) : undefined;
const agent = isHarnessModule(text);
const { agent, piSessions } = harnessModule(text);
const fx = isFxModule(text);
const chat = /\bchatRoute\s*\(/.test(text);
const wake = /\bwakeHandler\s*\(/.test(code(text));
const piSessions = opensPiSessions(text);
if (agent || fx || piSessions || (route && (chat || wake))) out.push({ file: full, agent, fx, chat, wake, ...(piSessions ? { piSessions } : {}), ...(route ? { route } : {}) });
}
}
Expand Down
Loading
Loading