feat(agent): add bounded execution controller
This commit is contained in:
@@ -8,6 +8,8 @@ AI Parallel v2 uses a live iframe workspace instead of mirroring model responses
|
||||
Workspace extension page
|
||||
├─ Provider Task Runtime
|
||||
│ └─ Provider Adapter Contract → shared iframe/tab transport
|
||||
├─ Agent Execution Controller
|
||||
│ └─ bounded planner / executor / reviewer aggregation
|
||||
├─ ChatGPT iframe
|
||||
├─ DeepSeek iframe
|
||||
├─ 智谱 iframe
|
||||
@@ -33,6 +35,13 @@ tracks each operation in memory, bounds retry attempts, exposes timeout and
|
||||
cancellation states, and keeps deterministic provider failures visible. Task
|
||||
history and response snapshots are not persisted.
|
||||
|
||||
The shared Agent Execution Controller provides an explicit, bounded parallel
|
||||
execution model for planner, executor, and reviewer roles. It allows at most
|
||||
eight agents per execution and three concurrent provider tasks by default. Each
|
||||
agent still runs through the Provider Task Runtime and Adapter Contract; one
|
||||
failure is isolated and reported in the aggregate. Prompts and responses remain
|
||||
in memory only, with no autonomous loop or background execution.
|
||||
|
||||
## Install from a release
|
||||
|
||||
Download and extract `ai-parallel-browser-extension-vX.Y.Z.zip`. Open
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"version": "2.1.4",
|
||||
"description": "Manifest V3 workspace for comparing AI web chats in parallel.",
|
||||
"scripts": {
|
||||
"check": "node --check shared/provider-catalog.js && node --check shared/provider-adapter-contract.js && node --check shared/provider-task-runtime.js && node --check service-worker.js && node --check popup.js && node --check content/frame-bridge.js && node --check workspace/workspace.js"
|
||||
"check": "node --check shared/provider-catalog.js && node --check shared/provider-adapter-contract.js && node --check shared/provider-task-runtime.js && node --check shared/agent-execution-controller.js && node --check service-worker.js && node --check popup.js && node --check content/frame-bridge.js && node --check workspace/workspace.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
(() => {
|
||||
const REQUIRED_ADAPTER_METHODS = Object.freeze([
|
||||
"sendPrompt",
|
||||
"collectResponse",
|
||||
"newChat",
|
||||
"healthCheck"
|
||||
]);
|
||||
const ROLES = Object.freeze(["planner", "executor", "reviewer"]);
|
||||
const STATUS = Object.freeze({
|
||||
IDLE: "IDLE",
|
||||
QUEUED: "QUEUED",
|
||||
RUNNING: "RUNNING",
|
||||
SUCCESS: "SUCCESS",
|
||||
PARTIAL: "PARTIAL",
|
||||
FAILED: "FAILED",
|
||||
CANCELLED: "CANCELLED"
|
||||
});
|
||||
const AGENT_STATUS = Object.freeze({
|
||||
PENDING: "PENDING",
|
||||
RUNNING: "RUNNING",
|
||||
SUCCESS: "SUCCESS",
|
||||
FAILED: "FAILED",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
CANCELLED: "CANCELLED"
|
||||
});
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
STATUS.SUCCESS,
|
||||
STATUS.PARTIAL,
|
||||
STATUS.FAILED,
|
||||
STATUS.CANCELLED
|
||||
]);
|
||||
const DEFAULT_MAX_CONCURRENCY = 3;
|
||||
const DEFAULT_MAX_AGENTS = 8;
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_MAX_HISTORY = 50;
|
||||
let executionSequence = 0;
|
||||
|
||||
function defaultIdFactory() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
executionSequence += 1;
|
||||
return `agent-execution-${Date.now()}-${executionSequence}`;
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? Math.max(1, Math.floor(number)) : fallback;
|
||||
}
|
||||
|
||||
function asMessage(error, fallback = "Agent execution failed") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error.trim()) return error.trim();
|
||||
if (error && typeof error.message === "string" && error.message.trim()) return error.message.trim();
|
||||
if (error && typeof error.error === "string" && error.error.trim()) return error.error.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function asErrorSnapshot(error, fallback) {
|
||||
return {
|
||||
message: asMessage(error, fallback),
|
||||
code: typeof error?.code === "string" ? error.code : null,
|
||||
retryable: error?.retryable === true
|
||||
};
|
||||
}
|
||||
|
||||
function adapterIsValid(adapter) {
|
||||
const contract = globalThis.AIParallelProviderAdapterContract;
|
||||
if (contract?.validateProviderAdapter) return contract.validateProviderAdapter(adapter);
|
||||
return Boolean(
|
||||
adapter
|
||||
&& typeof adapter.id === "string"
|
||||
&& typeof adapter.providerId === "string"
|
||||
&& REQUIRED_ADAPTER_METHODS.every((method) => typeof adapter[method] === "function")
|
||||
);
|
||||
}
|
||||
|
||||
function toAdapterMap(adapters) {
|
||||
if (adapters instanceof Map) return new Map(adapters);
|
||||
if (adapters && typeof adapters === "object") return new Map(Object.entries(adapters));
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function snapshotAgent(agent) {
|
||||
return {
|
||||
id: agent.id,
|
||||
providerId: agent.providerId,
|
||||
role: agent.role,
|
||||
taskId: agent.taskId,
|
||||
status: agent.status,
|
||||
attempt: agent.attempt,
|
||||
maxAttempts: agent.maxAttempts,
|
||||
startedAt: agent.startedAt,
|
||||
finishedAt: agent.finishedAt,
|
||||
error: agent.error ? { ...agent.error } : null,
|
||||
response: agent.response
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotExecution(execution) {
|
||||
return {
|
||||
id: execution.id,
|
||||
taskId: execution.taskId,
|
||||
strategy: execution.strategy,
|
||||
status: execution.status,
|
||||
ok: execution.status === STATUS.SUCCESS || execution.status === STATUS.PARTIAL,
|
||||
cancelRequested: execution.cancelRequested,
|
||||
startedAt: execution.startedAt,
|
||||
finishedAt: execution.finishedAt,
|
||||
maxConcurrency: execution.maxConcurrency,
|
||||
scope: { ...execution.scope },
|
||||
agents: execution.agents.map(snapshotAgent),
|
||||
responses: execution.responses.map((response) => ({ ...response })),
|
||||
error: execution.error
|
||||
};
|
||||
}
|
||||
|
||||
class AgentExecutionController {
|
||||
constructor({
|
||||
taskRuntime,
|
||||
adapters,
|
||||
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
|
||||
maxAgents = DEFAULT_MAX_AGENTS,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxHistory = DEFAULT_MAX_HISTORY,
|
||||
idFactory = defaultIdFactory,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
if (!taskRuntime || typeof taskRuntime.run !== "function") {
|
||||
throw new TypeError("Agent execution controller requires a provider task runtime");
|
||||
}
|
||||
|
||||
this.taskRuntime = taskRuntime;
|
||||
this.adapters = toAdapterMap(adapters);
|
||||
this.maxConcurrency = positiveInteger(maxConcurrency, DEFAULT_MAX_CONCURRENCY);
|
||||
this.maxAgents = Math.min(positiveInteger(maxAgents, DEFAULT_MAX_AGENTS), DEFAULT_MAX_AGENTS);
|
||||
this.timeoutMs = positiveInteger(timeoutMs, DEFAULT_TIMEOUT_MS);
|
||||
this.maxHistory = positiveInteger(maxHistory, DEFAULT_MAX_HISTORY);
|
||||
this.idFactory = idFactory;
|
||||
this.now = now;
|
||||
this.executions = new Map();
|
||||
this.finishedExecutionIds = [];
|
||||
this.listeners = new Set();
|
||||
|
||||
for (const adapter of this.adapters.values()) {
|
||||
if (!adapterIsValid(adapter)) {
|
||||
throw new TypeError("Agent execution controller received an invalid provider adapter");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("Agent execution listener must be a function");
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
createExecution({
|
||||
taskId,
|
||||
strategy = "parallel",
|
||||
prompt,
|
||||
agents,
|
||||
scope = {},
|
||||
maxConcurrency = this.maxConcurrency,
|
||||
timeoutMs = this.timeoutMs
|
||||
} = {}) {
|
||||
if (strategy !== "parallel") throw new TypeError("Only the parallel agent strategy is supported");
|
||||
if (typeof prompt !== "string" || !prompt.trim()) throw new TypeError("Agent execution requires a prompt");
|
||||
if (!Array.isArray(agents) || agents.length === 0) {
|
||||
throw new TypeError("Agent execution requires at least one agent");
|
||||
}
|
||||
if (agents.length > this.maxAgents) {
|
||||
throw new RangeError(`Agent execution is limited to ${this.maxAgents} agents`);
|
||||
}
|
||||
|
||||
const executionId = String(taskId || this.idFactory());
|
||||
if (!executionId.trim()) throw new TypeError("Agent execution requires a task ID");
|
||||
if (this.executions.has(executionId)) throw new Error(`Agent execution already exists: ${executionId}`);
|
||||
|
||||
const normalizedConcurrency = Math.min(
|
||||
positiveInteger(maxConcurrency, this.maxConcurrency),
|
||||
this.maxAgents
|
||||
);
|
||||
const normalizedTimeout = positiveInteger(timeoutMs, this.timeoutMs);
|
||||
const normalizedAgents = agents.map((agent, index) => this.normalizeAgent(agent, index, executionId));
|
||||
const agentIds = new Set();
|
||||
for (const agent of normalizedAgents) {
|
||||
if (agentIds.has(agent.id)) throw new Error(`Agent IDs must be unique: ${agent.id}`);
|
||||
agentIds.add(agent.id);
|
||||
}
|
||||
const scopeInput = scope && typeof scope === "object" ? scope : {};
|
||||
const execution = {
|
||||
id: executionId,
|
||||
taskId: executionId,
|
||||
strategy,
|
||||
status: STATUS.IDLE,
|
||||
cancelRequested: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
maxConcurrency: normalizedConcurrency,
|
||||
scope: {
|
||||
id: String(scopeInput.id || executionId),
|
||||
label: String(scopeInput.label || "bounded-agent-execution"),
|
||||
providerIds: normalizedAgents.map((agent) => agent.providerId),
|
||||
roles: normalizedAgents.map((agent) => agent.role)
|
||||
},
|
||||
agents: normalizedAgents,
|
||||
responses: [],
|
||||
error: null
|
||||
};
|
||||
const record = {
|
||||
execution,
|
||||
prompt: prompt.trim(),
|
||||
timeoutMs: normalizedTimeout,
|
||||
nextAgentIndex: 0,
|
||||
activeCount: 0,
|
||||
activeTasks: new Map(),
|
||||
cancelReason: "Agent execution cancelled",
|
||||
cancelRequested: false,
|
||||
completed: false,
|
||||
resolve: null,
|
||||
promise: null,
|
||||
pump: null
|
||||
};
|
||||
|
||||
Object.defineProperty(execution, "run", {
|
||||
enumerable: false,
|
||||
value: () => this.start(execution.id)
|
||||
});
|
||||
Object.defineProperty(execution, "cancel", {
|
||||
enumerable: false,
|
||||
value: (reason) => this.cancel(execution.id, reason)
|
||||
});
|
||||
|
||||
this.executions.set(execution.id, record);
|
||||
this.notify(execution);
|
||||
return execution;
|
||||
}
|
||||
|
||||
run(options) {
|
||||
const execution = this.createExecution(options);
|
||||
const promise = this.start(execution.id);
|
||||
promise.execution = execution;
|
||||
promise.executionId = execution.id;
|
||||
promise.cancel = execution.cancel;
|
||||
return promise;
|
||||
}
|
||||
|
||||
start(executionOrId) {
|
||||
const executionId = typeof executionOrId === "string" ? executionOrId : executionOrId?.id;
|
||||
const record = this.executions.get(executionId);
|
||||
if (!record) return Promise.reject(new Error("Unknown agent execution"));
|
||||
if (record.promise) return record.promise;
|
||||
|
||||
record.promise = new Promise((resolve) => {
|
||||
record.resolve = resolve;
|
||||
});
|
||||
this.setStatus(record.execution, STATUS.QUEUED);
|
||||
Promise.resolve().then(() => this.runRecord(record));
|
||||
return record.promise;
|
||||
}
|
||||
|
||||
cancel(executionOrId, reason = "Agent execution cancelled") {
|
||||
const executionId = typeof executionOrId === "string" ? executionOrId : executionOrId?.id;
|
||||
const record = this.executions.get(executionId);
|
||||
if (!record || record.completed) return false;
|
||||
|
||||
record.cancelRequested = true;
|
||||
record.cancelReason = asMessage(reason, "Agent execution cancelled");
|
||||
record.execution.cancelRequested = true;
|
||||
this.cancelQueuedAgents(record);
|
||||
for (const task of record.activeTasks.values()) task.cancel?.(record.cancelReason);
|
||||
this.notify(record.execution);
|
||||
if (record.activeCount === 0) this.finish(record, STATUS.CANCELLED);
|
||||
return true;
|
||||
}
|
||||
|
||||
getExecution(executionId) {
|
||||
const record = this.executions.get(String(executionId));
|
||||
return record ? snapshotExecution(record.execution) : null;
|
||||
}
|
||||
|
||||
listExecutions({ includeFinished = true } = {}) {
|
||||
return [...this.executions.values()]
|
||||
.filter((record) => includeFinished || !TERMINAL_STATUSES.has(record.execution.status))
|
||||
.map((record) => snapshotExecution(record.execution));
|
||||
}
|
||||
|
||||
normalizeAgent(agent, index, executionId) {
|
||||
if (!agent || typeof agent !== "object") throw new TypeError(`Agent ${index + 1} is invalid`);
|
||||
if (typeof agent.providerId !== "string" || !agent.providerId.trim()) {
|
||||
throw new TypeError(`Agent ${index + 1} requires a provider ID`);
|
||||
}
|
||||
if (!ROLES.includes(agent.role)) {
|
||||
throw new TypeError(`Agent ${index + 1} role must be planner, executor, or reviewer`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterFor(agent.providerId);
|
||||
if (!adapter) throw new Error(`Provider adapter not found: ${agent.providerId}`);
|
||||
|
||||
const id = String(agent.id || `${executionId}:agent-${index + 1}`);
|
||||
return {
|
||||
id,
|
||||
providerId: agent.providerId,
|
||||
role: agent.role,
|
||||
taskId: null,
|
||||
status: AGENT_STATUS.PENDING,
|
||||
attempt: 0,
|
||||
maxAttempts: adapter.capabilities?.retry === true ? 2 : 1,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
error: null,
|
||||
response: null
|
||||
};
|
||||
}
|
||||
|
||||
adapterFor(providerId) {
|
||||
const direct = this.adapters.get(providerId);
|
||||
if (direct?.providerId === providerId) return direct;
|
||||
for (const adapter of this.adapters.values()) {
|
||||
if (adapter?.providerId === providerId) return adapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
runRecord(record) {
|
||||
if (record.completed) return;
|
||||
if (record.cancelRequested) {
|
||||
this.cancelQueuedAgents(record);
|
||||
this.finish(record, STATUS.CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
record.execution.startedAt = this.now();
|
||||
this.setStatus(record.execution, STATUS.RUNNING);
|
||||
record.pump = () => this.pump(record);
|
||||
record.pump();
|
||||
}
|
||||
|
||||
pump(record) {
|
||||
if (record.completed) return;
|
||||
if (record.cancelRequested) {
|
||||
this.cancelQueuedAgents(record);
|
||||
if (record.activeCount === 0) this.finish(record, STATUS.CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
record.activeCount < record.execution.maxConcurrency
|
||||
&& record.nextAgentIndex < record.execution.agents.length
|
||||
) {
|
||||
const agent = record.execution.agents[record.nextAgentIndex++];
|
||||
record.activeCount += 1;
|
||||
const agentPromise = this.startAgent(record, agent);
|
||||
agentPromise.then(
|
||||
() => this.agentFinished(record, agent.id),
|
||||
() => this.agentFinished(record, agent.id)
|
||||
);
|
||||
}
|
||||
|
||||
if (record.nextAgentIndex >= record.execution.agents.length && record.activeCount === 0) {
|
||||
this.finish(record, this.finalStatus(record));
|
||||
}
|
||||
}
|
||||
|
||||
agentFinished(record, agentId) {
|
||||
record.activeCount = Math.max(0, record.activeCount - 1);
|
||||
record.activeTasks.delete(agentId);
|
||||
if (!record.completed) record.pump?.();
|
||||
}
|
||||
|
||||
async startAgent(record, agent) {
|
||||
const adapter = this.adapterFor(agent.providerId);
|
||||
agent.status = AGENT_STATUS.RUNNING;
|
||||
agent.startedAt = this.now();
|
||||
this.notify(record.execution);
|
||||
|
||||
try {
|
||||
if (record.cancelRequested) {
|
||||
this.markAgentCancelled(agent, record.cancelReason);
|
||||
return;
|
||||
}
|
||||
const taskPromise = this.taskRuntime.run({
|
||||
providerId: agent.providerId,
|
||||
operation: `agent-${agent.role}`,
|
||||
timeoutMs: record.timeoutMs,
|
||||
maxAttempts: agent.maxAttempts,
|
||||
retryOn: (error) => error?.retryable === true,
|
||||
execute: ({ signal, attempt }) => adapter.sendPrompt(
|
||||
this.scopedPrompt(record.prompt, record.execution, agent),
|
||||
{
|
||||
signal,
|
||||
attempt,
|
||||
timeoutMs: record.timeoutMs,
|
||||
executionId: record.execution.id,
|
||||
scopeId: record.execution.scope.id,
|
||||
agentId: agent.id,
|
||||
role: agent.role
|
||||
}
|
||||
)
|
||||
});
|
||||
record.activeTasks.set(agent.id, taskPromise);
|
||||
agent.taskId = taskPromise.taskId || taskPromise.task?.id || null;
|
||||
this.notify(record.execution);
|
||||
const result = await taskPromise;
|
||||
agent.attempt = taskPromise.task?.attempt || agent.attempt;
|
||||
|
||||
if (record.cancelRequested || result?.status === "CANCELLED") {
|
||||
this.markAgentCancelled(agent, record.cancelReason);
|
||||
} else if (result?.ok === false) {
|
||||
agent.status = result.status === "TIMEOUT" ? AGENT_STATUS.TIMEOUT : AGENT_STATUS.FAILED;
|
||||
agent.error = asErrorSnapshot(result, result.status || "Agent task failed");
|
||||
} else {
|
||||
agent.status = AGENT_STATUS.SUCCESS;
|
||||
agent.response = result?.response ?? result;
|
||||
agent.error = null;
|
||||
}
|
||||
} catch (error) {
|
||||
agent.attempt = agent.attempt || 1;
|
||||
if (record.cancelRequested || error?.code === "TASK_CANCELLED") {
|
||||
this.markAgentCancelled(agent, record.cancelReason);
|
||||
} else {
|
||||
agent.status = error?.code === "TASK_TIMEOUT" ? AGENT_STATUS.TIMEOUT : AGENT_STATUS.FAILED;
|
||||
agent.error = asErrorSnapshot(error);
|
||||
}
|
||||
} finally {
|
||||
agent.finishedAt = this.now();
|
||||
this.notify(record.execution);
|
||||
}
|
||||
}
|
||||
|
||||
scopedPrompt(prompt, execution, agent) {
|
||||
return [
|
||||
`You are the ${agent.role} agent in a bounded AI Parallel execution.`,
|
||||
`Execution scope: ${execution.scope.label} (${execution.scope.id}).`,
|
||||
"Follow the requested scope and return only the useful result.",
|
||||
"",
|
||||
prompt
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
markAgentCancelled(agent, reason) {
|
||||
agent.status = AGENT_STATUS.CANCELLED;
|
||||
agent.error = asErrorSnapshot({ code: "AGENT_CANCELLED", retryable: false }, reason);
|
||||
agent.response = null;
|
||||
}
|
||||
|
||||
cancelQueuedAgents(record) {
|
||||
for (let index = record.nextAgentIndex; index < record.execution.agents.length; index += 1) {
|
||||
const agent = record.execution.agents[index];
|
||||
if (agent.status !== AGENT_STATUS.PENDING) continue;
|
||||
this.markAgentCancelled(agent, record.cancelReason);
|
||||
agent.finishedAt = this.now();
|
||||
}
|
||||
record.nextAgentIndex = record.execution.agents.length;
|
||||
this.notify(record.execution);
|
||||
}
|
||||
|
||||
finalStatus(record) {
|
||||
if (record.cancelRequested) return STATUS.CANCELLED;
|
||||
const succeeded = record.execution.agents.filter((agent) => agent.status === AGENT_STATUS.SUCCESS).length;
|
||||
return succeeded === record.execution.agents.length
|
||||
? STATUS.SUCCESS
|
||||
: succeeded > 0 ? STATUS.PARTIAL : STATUS.FAILED;
|
||||
}
|
||||
|
||||
finish(record, status) {
|
||||
if (record.completed) return;
|
||||
record.completed = true;
|
||||
const execution = record.execution;
|
||||
execution.status = status;
|
||||
execution.finishedAt = this.now();
|
||||
execution.error = status === STATUS.SUCCESS || status === STATUS.PARTIAL
|
||||
? null
|
||||
: status === STATUS.CANCELLED ? record.cancelReason : "All agent tasks failed";
|
||||
execution.responses = execution.agents
|
||||
.filter((agent) => agent.status === AGENT_STATUS.SUCCESS)
|
||||
.map((agent) => ({
|
||||
agentId: agent.id,
|
||||
providerId: agent.providerId,
|
||||
role: agent.role,
|
||||
response: agent.response
|
||||
}));
|
||||
const result = snapshotExecution(execution);
|
||||
record.prompt = null;
|
||||
record.activeTasks.clear();
|
||||
this.finishedExecutionIds.push(execution.id);
|
||||
while (this.finishedExecutionIds.length > this.maxHistory) {
|
||||
this.executions.delete(this.finishedExecutionIds.shift());
|
||||
}
|
||||
this.notify(execution);
|
||||
record.resolve?.(result);
|
||||
}
|
||||
|
||||
setStatus(execution, status) {
|
||||
if (execution.status === status) return;
|
||||
execution.status = status;
|
||||
this.notify(execution);
|
||||
}
|
||||
|
||||
notify(execution) {
|
||||
const snapshot = snapshotExecution(execution);
|
||||
for (const listener of this.listeners) {
|
||||
try { listener(snapshot); } catch { /* Observers must not affect agent execution. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.AIParallelAgentExecutionController = Object.freeze({
|
||||
ROLES,
|
||||
STATUS,
|
||||
AGENT_STATUS,
|
||||
TERMINAL_STATUSES: Object.freeze([...TERMINAL_STATUSES]),
|
||||
AgentExecutionController,
|
||||
createAgentExecutionController(options) {
|
||||
return new AgentExecutionController(options);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -134,6 +134,7 @@
|
||||
<script src="../shared/provider-catalog.js"></script>
|
||||
<script src="../shared/provider-adapter-contract.js"></script>
|
||||
<script src="../shared/provider-task-runtime.js"></script>
|
||||
<script src="../shared/agent-execution-controller.js"></script>
|
||||
<script src="context-utils.js"></script>
|
||||
<script src="workspace.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -11,6 +11,10 @@ AI Parallel Workspace (extension page)
|
||||
├─ Provider Task Runtime
|
||||
│ ├─ bounded retry / timeout / cancel
|
||||
│ └─ in-memory task state and observation
|
||||
├─ Agent Execution Controller
|
||||
│ ├─ explicit planner / executor / reviewer scope
|
||||
│ ├─ bounded parallel scheduling
|
||||
│ └─ in-memory result aggregation and failure isolation
|
||||
├─ ChatGPT iframe
|
||||
├─ DeepSeek iframe
|
||||
├─ 智谱 iframe
|
||||
@@ -76,6 +80,15 @@ Tasks expose `IDLE`, `QUEUED`, `RUNNING`, `SUCCESS`, `FAILED`, `TIMEOUT`, and
|
||||
only explicitly retryable transport or timeout failures are retried. Task history
|
||||
and response snapshots are not written to extension storage.
|
||||
|
||||
The Agent Execution Controller is a user-triggered, in-memory layer above the
|
||||
Provider Task Runtime. It accepts an explicit `parallel` scope containing no
|
||||
more than eight planner, executor, or reviewer agents, schedules at most three
|
||||
agents concurrently by default, and sends every agent prompt through the
|
||||
selected Provider Adapter and Runtime. A failed agent is isolated so remaining
|
||||
agents can complete; the aggregate is `SUCCESS`, `PARTIAL`, `FAILED`, or
|
||||
`CANCELLED`. It does not create autonomous loops, persist prompts/responses, or
|
||||
bypass the provider message boundary.
|
||||
|
||||
## Provider boundaries
|
||||
|
||||
Provider-specific DOM knowledge is isolated in `content/providers/<provider>.js`,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"version": "2.1.4",
|
||||
"description": "Monorepo for parallel AI workflows across browser, web and desktop clients.",
|
||||
"scripts": {
|
||||
"check": "node --check apps/browser-extension/shared/provider-catalog.js && node --check apps/browser-extension/shared/provider-adapter-contract.js && node --check apps/browser-extension/shared/provider-task-runtime.js && node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/providers/core.js && node --check apps/browser-extension/content/providers/chatgpt.js && node --check apps/browser-extension/content/providers/deepseek.js && node --check apps/browser-extension/content/providers/qwen.js && node --check apps/browser-extension/content/providers/kimi.js && node --check apps/browser-extension/content/providers/zhipu.js && node --check apps/browser-extension/content/providers/claude.js && node --check apps/browser-extension/content/providers/gemini.js && node --check apps/browser-extension/content/providers/grok.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/context-utils.js && node --check apps/browser-extension/workspace/workspace.js",
|
||||
"check": "node --check apps/browser-extension/shared/provider-catalog.js && node --check apps/browser-extension/shared/provider-adapter-contract.js && node --check apps/browser-extension/shared/provider-task-runtime.js && node --check apps/browser-extension/shared/agent-execution-controller.js && node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/providers/core.js && node --check apps/browser-extension/content/providers/chatgpt.js && node --check apps/browser-extension/content/providers/deepseek.js && node --check apps/browser-extension/content/providers/qwen.js && node --check apps/browser-extension/content/providers/kimi.js && node --check apps/browser-extension/content/providers/zhipu.js && node --check apps/browser-extension/content/providers/claude.js && node --check apps/browser-extension/content/providers/gemini.js && node --check apps/browser-extension/content/providers/grok.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/context-utils.js && node --check apps/browser-extension/workspace/workspace.js",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"package:extension": "bash scripts/package-extension.sh"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
|
||||
|
||||
function loadController() {
|
||||
const context = { console, setTimeout, clearTimeout, AbortController };
|
||||
context.globalThis = context;
|
||||
vm.createContext(context);
|
||||
for (const file of [
|
||||
"shared/provider-task-runtime.js",
|
||||
"shared/provider-adapter-contract.js",
|
||||
"shared/agent-execution-controller.js"
|
||||
]) {
|
||||
vm.runInContext(fs.readFileSync(path.join(extensionRoot, file), "utf8"), context, { filename: file });
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function createAdapter(providerId, sendPrompt, { retry = false } = {}) {
|
||||
return {
|
||||
id: providerId,
|
||||
providerId,
|
||||
capabilities: { retry },
|
||||
sendPrompt,
|
||||
collectResponse() { return Promise.resolve({ ok: true }); },
|
||||
newChat() { return Promise.resolve({ ok: true }); },
|
||||
healthCheck() { return Promise.resolve({ ok: true }); }
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
test("agent execution runs through provider runtime with bounded parallelism", async () => {
|
||||
const context = loadController();
|
||||
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
|
||||
defaultTimeoutMs: 1000,
|
||||
idFactory: (() => {
|
||||
let sequence = 0;
|
||||
return () => `provider-task-${++sequence}`;
|
||||
})()
|
||||
});
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls = [];
|
||||
const adapters = {};
|
||||
for (const providerId of ["chatgpt", "claude", "gemini", "grok"]) {
|
||||
adapters[providerId] = createAdapter(providerId, (prompt, adapterContext) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
calls.push({ providerId, prompt, context: adapterContext });
|
||||
return new Promise((resolve) => setTimeout(() => {
|
||||
active -= 1;
|
||||
resolve({ ok: true, response: { content: `${providerId} result` } });
|
||||
}, 8));
|
||||
});
|
||||
}
|
||||
|
||||
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
|
||||
taskRuntime: runtime,
|
||||
adapters,
|
||||
maxConcurrency: 2,
|
||||
maxAgents: 8,
|
||||
idFactory: () => "execution-1"
|
||||
});
|
||||
const events = [];
|
||||
controller.subscribe((snapshot) => events.push(snapshot.status));
|
||||
|
||||
const promise = controller.run({
|
||||
taskId: "research-task",
|
||||
prompt: "Compare the current approaches and identify the strongest result.",
|
||||
scope: { id: "research-scope", label: "bounded research" },
|
||||
agents: [
|
||||
{ providerId: "chatgpt", role: "planner" },
|
||||
{ providerId: "claude", role: "executor" },
|
||||
{ providerId: "gemini", role: "executor" },
|
||||
{ providerId: "grok", role: "reviewer" }
|
||||
]
|
||||
});
|
||||
const result = await promise;
|
||||
|
||||
assert.equal(result.status, "SUCCESS");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.strategy, "parallel");
|
||||
assert.equal(result.scope.id, "research-scope");
|
||||
assert.equal(result.maxConcurrency, 2);
|
||||
assert.equal(result.agents.length, 4);
|
||||
assert.ok(result.agents.every((agent) => agent.status === "SUCCESS"));
|
||||
assert.equal(result.responses.length, 4);
|
||||
assert.equal("prompt" in result, false);
|
||||
assert.equal(maxActive, 2);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.ok(calls.every(({ prompt }) => prompt.includes("Compare the current approaches")));
|
||||
assert.ok(calls.every(({ context: adapterContext }) => adapterContext.executionId === "research-task"));
|
||||
assert.ok(calls.every(({ context: adapterContext }) => adapterContext.scopeId === "research-scope"));
|
||||
assert.deepEqual(
|
||||
[...runtime.listTasks().map((task) => task.operation)].sort(),
|
||||
["agent-executor", "agent-executor", "agent-planner", "agent-reviewer"]
|
||||
);
|
||||
assert.deepEqual(events.slice(0, 3), ["IDLE", "QUEUED", "RUNNING"]);
|
||||
assert.equal(events.at(-1), "SUCCESS");
|
||||
});
|
||||
|
||||
test("one agent failure is isolated and produces a partial aggregate", async () => {
|
||||
const context = loadController();
|
||||
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
|
||||
defaultTimeoutMs: 1000,
|
||||
idFactory: (() => {
|
||||
let sequence = 0;
|
||||
return () => `provider-task-${++sequence}`;
|
||||
})()
|
||||
});
|
||||
const calls = [];
|
||||
const adapters = {
|
||||
chatgpt: createAdapter("chatgpt", () => Promise.resolve({
|
||||
ok: true,
|
||||
response: { content: "planned" }
|
||||
})),
|
||||
claude: createAdapter("claude", () => {
|
||||
calls.push("failed");
|
||||
return Promise.resolve({ ok: false, error: "provider unavailable", code: "UNAVAILABLE" });
|
||||
}),
|
||||
gemini: createAdapter("gemini", () => {
|
||||
calls.push("continued");
|
||||
return Promise.resolve({ ok: true, response: { content: "reviewed" } });
|
||||
})
|
||||
};
|
||||
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
|
||||
taskRuntime: runtime,
|
||||
adapters,
|
||||
maxConcurrency: 1,
|
||||
idFactory: () => "execution-2"
|
||||
});
|
||||
|
||||
const result = await controller.run({
|
||||
prompt: "Run each bounded role independently.",
|
||||
agents: [
|
||||
{ providerId: "chatgpt", role: "planner" },
|
||||
{ providerId: "claude", role: "executor" },
|
||||
{ providerId: "gemini", role: "reviewer" }
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(result.status, "PARTIAL");
|
||||
assert.equal(result.agents.filter((agent) => agent.status === "SUCCESS").length, 2);
|
||||
assert.equal(result.agents.find((agent) => agent.providerId === "claude").status, "FAILED");
|
||||
assert.equal(result.agents.find((agent) => agent.providerId === "claude").error.message, "provider unavailable");
|
||||
assert.equal(result.responses.length, 2);
|
||||
assert.equal("prompt" in result, false);
|
||||
assert.deepEqual(calls, ["failed", "continued"]);
|
||||
assert.equal(runtime.listTasks({ includeFinished: false }).length, 0);
|
||||
});
|
||||
|
||||
test("cancelling an execution cancels active and queued agents", async () => {
|
||||
const context = loadController();
|
||||
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime({
|
||||
defaultTimeoutMs: 1000,
|
||||
idFactory: () => "provider-task-cancel"
|
||||
});
|
||||
let providerStarted;
|
||||
const providerStartedPromise = new Promise((resolve) => { providerStarted = resolve; });
|
||||
const adapters = {
|
||||
chatgpt: createAdapter("chatgpt", (_prompt, { signal }) => new Promise((resolve) => {
|
||||
providerStarted();
|
||||
signal.addEventListener("abort", () => resolve({
|
||||
ok: false,
|
||||
error: "cancelled",
|
||||
code: "TASK_CANCELLED"
|
||||
}), { once: true });
|
||||
})),
|
||||
claude: createAdapter("claude", () => Promise.resolve({ ok: true }))
|
||||
};
|
||||
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
|
||||
taskRuntime: runtime,
|
||||
adapters,
|
||||
maxConcurrency: 1,
|
||||
idFactory: () => "execution-3"
|
||||
});
|
||||
const promise = controller.run({
|
||||
prompt: "This execution should be cancelled.",
|
||||
agents: [
|
||||
{ providerId: "chatgpt", role: "planner" },
|
||||
{ providerId: "claude", role: "reviewer" }
|
||||
]
|
||||
});
|
||||
|
||||
await providerStartedPromise;
|
||||
assert.equal(promise.cancel("user stopped the run"), true);
|
||||
const result = await promise;
|
||||
|
||||
assert.equal(result.status, "CANCELLED");
|
||||
assert.equal(result.error, "user stopped the run");
|
||||
assert.ok(result.agents.every((agent) => agent.status === "CANCELLED"));
|
||||
assert.equal(controller.cancel("execution-3"), false);
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
test("agent scope validation enforces bounded roles and agent count", () => {
|
||||
const context = loadController();
|
||||
const runtime = context.AIParallelProviderTaskRuntime.createProviderTaskRuntime();
|
||||
const adapter = createAdapter("chatgpt", () => Promise.resolve({ ok: true }));
|
||||
const controller = context.AIParallelAgentExecutionController.createAgentExecutionController({
|
||||
taskRuntime: runtime,
|
||||
adapters: { chatgpt: adapter },
|
||||
maxAgents: 2
|
||||
});
|
||||
|
||||
assert.throws(() => controller.createExecution({
|
||||
prompt: "x",
|
||||
agents: [{ providerId: "chatgpt", role: "scheduler" }]
|
||||
}), /planner, executor, or reviewer/);
|
||||
assert.throws(() => controller.createExecution({
|
||||
prompt: "x",
|
||||
agents: [
|
||||
{ providerId: "chatgpt", role: "planner" },
|
||||
{ providerId: "chatgpt", role: "executor" },
|
||||
{ providerId: "chatgpt", role: "reviewer" }
|
||||
]
|
||||
}), /limited to 2 agents/);
|
||||
assert.throws(() => controller.createExecution({
|
||||
strategy: "sequential",
|
||||
prompt: "x",
|
||||
agents: [{ providerId: "chatgpt", role: "planner" }]
|
||||
}), /parallel/);
|
||||
});
|
||||
Reference in New Issue
Block a user