Merge v3-t66: route import, branch start model, static check for undefined names in extensions (T66)
This commit is contained in:
@@ -8,6 +8,7 @@ They use world files and world git. They do not use native filesystem or process
|
||||
`branch` requires an absolute `repo` path and a prompt.
|
||||
It forks a listed tree root and writes `board.started.by` in the same transaction.
|
||||
The coordinator definition supplies the branch configuration.
|
||||
The branch start event reports the coordinator's model, not the inherited root model.
|
||||
Worktree isolation requires the later workspaces port. The default is a shared checkout.
|
||||
|
||||
The tool admits the prompt as a reply-enabled message.
|
||||
@@ -22,6 +23,8 @@ A branch reports to its starter when its work ends.
|
||||
While a child, landing, or reply remains pending, its final answer becomes a passive note.
|
||||
One-way updates to the starter also become passive notes.
|
||||
The starter can answer a report without an endless exchange of routed answers.
|
||||
The starter's final answer reaches the branch without a faulted routing task.
|
||||
A static test checks free runtime identifiers in every extension module, including function bodies.
|
||||
|
||||
The grant chain contains fork ancestry before starter ancestry.
|
||||
Each conversation occurs once. A root fork that @ultimate starts includes `[branch, root, ultimate]`.
|
||||
|
||||
@@ -22,7 +22,7 @@ export const branchTool = (host: any) => defineTool({
|
||||
const coordinator = definitions.find((definition: any) => definition.name === 'coordinator')
|
||||
if (!coordinator) throw new Error('Missing coordinator definition')
|
||||
const rootAgent = await (await host.harness().conversation(root, ctx))!.agent(ctx)
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, model: coordinator.runs.model, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
|
||||
const existing = await workspaces?.get(branch.id, ctx)
|
||||
const cwd = existing ? (await (await host.harness().conversation(branch.id, ctx)).agent(ctx)).cwd : input.repo
|
||||
const ancestors = workspaces ? (await workspaces.chain(branch.id, ctx)).slice(1) : []
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
|
||||
|
||||
import { Subagents } from '../subagents/api.ts'
|
||||
import { ConversationsApi } from '../conversations/api.ts'
|
||||
|
||||
import { Messages } from './api.ts'
|
||||
import { terminal } from './log.ts'
|
||||
|
||||
@@ -31696,6 +31696,8 @@ var until2 = async (test) => {
|
||||
var models = createModels();
|
||||
var faux = fauxProvider({ tokensPerSecond: 1e5 });
|
||||
models.setProvider(faux.provider);
|
||||
var template = models.getModels("faux")[0];
|
||||
models.setProvider({ ...faux.provider, getModels: () => [template, { ...template, id: "coordinator" }] });
|
||||
var registry = createRegistry();
|
||||
var storage = await openStorage(globalThis.storagePath);
|
||||
var harness = await Harness.open(storage, { models, registry, settings: { get extensions() {
|
||||
@@ -31814,12 +31816,21 @@ try {
|
||||
assert((await entries2(branch)).some((e) => e.kind === "board.message" && e.data.text === "first task"), "The first message did not reach the branch");
|
||||
assert((await harness.commit((tx) => tx.conversation(branch.id), BACKGROUND_CONTEXT)).parent.conversationId === root.id, "Branch must fork its root");
|
||||
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).by === primary.id, "Missing starter");
|
||||
const starts = (await globalThis.testHost.eventLog.recent()).filter((event) => event.name === "subagents.started" && event.data.id === branch.id);
|
||||
assert(starts.length === 1 && starts[0].data.model === "faux/coordinator", `Effective branch model: ${JSON.stringify(starts)}`);
|
||||
assert((await branch.agent(BACKGROUND_CONTEXT)).model.modelId === "coordinator", "The branch must use its advertised model");
|
||||
assert(JSON.stringify(await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), "Fork and starter grant ancestry");
|
||||
const before = (await entries2(primary)).length;
|
||||
await (await branch.submit({ type: "input", content: "later work" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.text === "Answer: later work" && e.data.delivery !== "note"));
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
assert((await entries2(primary)).length > before, "An idle branch must report");
|
||||
assert((await entries2(branch)).some((entry2) => entry2.kind === "board.message" && entry2.data.from === primary.id && entry2.data.routed === true), "The final answer must route back to the branch");
|
||||
await until2(async () => {
|
||||
const routeTasks = (await harness.commit((tx) => tx.scanTasks({ kind: "board.route" }, 1e3), BACKGROUND_CONTEXT)).items;
|
||||
assert(routeTasks.every((task) => task.state.status !== "terminal" || task.state.outcome.status === "completed"), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
|
||||
return routeTasks.length > 0 && routeTasks.every((task) => task.state.status === "terminal");
|
||||
});
|
||||
await tool(branch, "subagent", { action: "start", agent: "scout", task: "HOLD", background: true });
|
||||
await until2(() => holding.size > 0);
|
||||
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.delivery === "note"));
|
||||
|
||||
@@ -10,6 +10,8 @@ import {openStorage} from './sqlite';
|
||||
const assert = (value: any, message: string) => {if (!value) throw new Error(message)};
|
||||
const until = async (test: () => any) => {const deadline = Date.now() + 15000; while (!await test()) {if (Date.now() > deadline) throw new Error('Messages deadline'); await new Promise(r => setTimeout(r, 0))}};
|
||||
const models = createModels(), faux = fauxProvider({tokensPerSecond: 100000}); models.setProvider(faux.provider);
|
||||
const template = models.getModels('faux')[0];
|
||||
models.setProvider({...faux.provider, getModels: () => [template, {...template, id: 'coordinator'}]});
|
||||
const registry = createRegistry(), storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, {models, registry, settings: {get extensions() {return registry.snapshot().installed()}}}, context);
|
||||
const root = await harness.root(context, {agent: {model: {provider: 'faux', modelId: 'faux-1'}, cwd: '/src/sausage'}});
|
||||
@@ -99,12 +101,21 @@ try {
|
||||
assert((await entries(branch)).some((e: any) => e.kind === 'board.message' && e.data.text === 'first task'), 'The first message did not reach the branch');
|
||||
assert((await harness.commit((tx: any) => tx.conversation(branch.id), context)).parent.conversationId === root.id, 'Branch must fork its root');
|
||||
assert((await harness.snapshot(Started, branch.id, context)).by === primary.id, 'Missing starter');
|
||||
const starts = (await globalThis.testHost.eventLog.recent()).filter((event: any) => event.name === 'subagents.started' && event.data.id === branch.id);
|
||||
assert(starts.length === 1 && starts[0].data.model === 'faux/coordinator', `Effective branch model: ${JSON.stringify(starts)}`);
|
||||
assert((await branch.agent(context)).model.modelId === 'coordinator', 'The branch must use its advertised model');
|
||||
assert(JSON.stringify(await conversationChains(harness, context)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), 'Fork and starter grant ancestry');
|
||||
const before = (await entries(primary)).length;
|
||||
await (await branch.submit({type: 'input', content: 'later work'}, context)).wait(context);
|
||||
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.text === 'Answer: later work' && e.data.delivery !== 'note'));
|
||||
await harness.waitForIdle(context);
|
||||
assert((await entries(primary)).length > before, 'An idle branch must report');
|
||||
assert((await entries(branch)).some((entry: any) => entry.kind === 'board.message' && entry.data.from === primary.id && entry.data.routed === true), 'The final answer must route back to the branch');
|
||||
await until(async () => {
|
||||
const routeTasks = (await harness.commit((tx: any) => tx.scanTasks({kind: 'board.route'}, 1000), context)).items;
|
||||
assert(routeTasks.every((task: any) => task.state.status !== 'terminal' || task.state.outcome.status === 'completed'), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
|
||||
return routeTasks.length > 0 && routeTasks.every((task: any) => task.state.status === 'terminal');
|
||||
});
|
||||
await tool(branch, 'subagent', {action: 'start', agent: 'scout', task: 'HOLD', background: true});
|
||||
await until(() => holding.size > 0);
|
||||
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.delivery === 'note'));
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Parse extension modules and reject free runtime identifiers outside the world runtime globals.
|
||||
use deno_ast::swc::{
|
||||
ast::{Ident, TsInterfaceDecl, TsType},
|
||||
common::SyntaxContext,
|
||||
ecma_visit::{Visit, VisitWith},
|
||||
};
|
||||
use deno_ast::{MediaType, ParseParams, ProgramRef, parse_module};
|
||||
use std::path::Path;
|
||||
|
||||
// Standard JavaScript globals. World extensions also see Deno.
|
||||
const GLOBALS: &[&str] = &[
|
||||
"undefined",
|
||||
"globalThis",
|
||||
"Deno",
|
||||
"console",
|
||||
"crypto",
|
||||
"fetch",
|
||||
"performance",
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"setInterval",
|
||||
"clearInterval",
|
||||
"queueMicrotask",
|
||||
"structuredClone",
|
||||
"atob",
|
||||
"btoa",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"isNaN",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
"Object",
|
||||
"Function",
|
||||
"Boolean",
|
||||
"Number",
|
||||
"BigInt",
|
||||
"String",
|
||||
"Symbol",
|
||||
"Array",
|
||||
"Map",
|
||||
"Set",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"WeakRef",
|
||||
"FinalizationRegistry",
|
||||
"Promise",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"AggregateError",
|
||||
"JSON",
|
||||
"Math",
|
||||
"Reflect",
|
||||
"Proxy",
|
||||
"Intl",
|
||||
"Iterator",
|
||||
"Uint8Array",
|
||||
"ArrayBuffer",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
"Request",
|
||||
"Response",
|
||||
"Headers",
|
||||
"ReadableStream",
|
||||
"WritableStream",
|
||||
"TransformStream",
|
||||
"Blob",
|
||||
"File",
|
||||
"FormData",
|
||||
"DOMException",
|
||||
"encodeURIComponent",
|
||||
"decodeURIComponent",
|
||||
];
|
||||
fn undefined(source: &str) -> Vec<String> {
|
||||
let parsed = parse_module(ParseParams {
|
||||
specifier: "file:///extension.ts".parse().unwrap(),
|
||||
text: source.into(),
|
||||
media_type: MediaType::TypeScript,
|
||||
capture_tokens: false,
|
||||
scope_analysis: true,
|
||||
maybe_syntax: None,
|
||||
})
|
||||
.unwrap();
|
||||
struct FreeNames {
|
||||
context: SyntaxContext,
|
||||
names: Vec<String>,
|
||||
}
|
||||
impl Visit for FreeNames {
|
||||
// Type signatures contain parameter names but do not execute.
|
||||
fn visit_ts_type(&mut self, _node: &TsType) {}
|
||||
fn visit_ts_interface_decl(&mut self, _node: &TsInterfaceDecl) {}
|
||||
fn visit_ident(&mut self, ident: &Ident) {
|
||||
if ident.ctxt == self.context && !GLOBALS.contains(&ident.sym.as_ref()) {
|
||||
self.names.push(ident.sym.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free = FreeNames {
|
||||
context: parsed.unresolved_context(),
|
||||
names: Vec::new(),
|
||||
};
|
||||
match parsed.program_ref() {
|
||||
ProgramRef::Module(module) => module.visit_with(&mut free),
|
||||
ProgramRef::Script(script) => script.visit_with(&mut free),
|
||||
}
|
||||
free.names.sort();
|
||||
free.names.dedup();
|
||||
free.names
|
||||
}
|
||||
fn scan(directory: &Path, failures: &mut Vec<String>) {
|
||||
for entry in std::fs::read_dir(directory).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
scan(&path, failures);
|
||||
} else if path.extension().is_some_and(|ext| ext == "ts")
|
||||
&& !path.to_string_lossy().ends_with(".test.ts")
|
||||
{
|
||||
let names = undefined(&std::fs::read_to_string(&path).unwrap());
|
||||
if !names.is_empty() {
|
||||
failures.push(format!("{}: {}", path.display(), names.join(", ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn extension_identifiers_are_defined() {
|
||||
let mut failures = Vec::new();
|
||||
scan(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
|
||||
&mut failures,
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"Undefined identifiers:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn catches_the_missing_route_import() {
|
||||
let source = std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions/messages/route.ts"),
|
||||
)
|
||||
.unwrap();
|
||||
let old = source.replace(
|
||||
"import { ConversationsApi } from '../conversations/api.ts'\n",
|
||||
"",
|
||||
);
|
||||
assert!(
|
||||
undefined(&old)
|
||||
.iter()
|
||||
.any(|name| name == "ConversationsApi")
|
||||
);
|
||||
assert!(undefined("import { Api } from 'service'; type Callback = (arg: UnknownType) => void; interface Service { use(parameter: UnknownType): void }; function use(host: any) { const local = Api; return host.need(local) }").is_empty());
|
||||
assert_eq!(
|
||||
undefined("function use(host: any) { return host.need(MissingApi) }"),
|
||||
["MissingApi"]
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
"extensions/core",
|
||||
"extensions/conversations",
|
||||
"agents",
|
||||
] {
|
||||
std::fs::create_dir_all(repo.join(directory))?;
|
||||
@@ -70,6 +71,10 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/conversations/api.ts"),
|
||||
repo.join("extensions/conversations/api.ts"),
|
||||
)?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
@@ -88,7 +93,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
"---\nname: coordinator\ndescription: Coordinate\nmodel: faux/coordinator\nisolate: none\n---\nCoordinator agent\n",
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
Reference in New Issue
Block a user