mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793f73786e | ||
|
|
a75c2a8b2a | ||
|
|
25a93f5246 | ||
|
|
aa80238d2d |
No files matched your search
@@ -17,26 +17,20 @@ The check is a ratchet: the total may fall, never rise.
|
||||
|
||||
| Surface | Options not yet acted on |
|
||||
|---|---|
|
||||
| `AgentTeam.__init__` | 15 |
|
||||
| `AgentTeam.__init__` | 8 |
|
||||
| `Handoff` | 8 |
|
||||
| `Task.__init__` | 32 |
|
||||
| **Total** | **55** |
|
||||
| `Task.__init__` | 4 |
|
||||
| **Total** | **20** |
|
||||
|
||||
Plus 12 options that work for some inputs and announce themselves for the rest.
|
||||
Plus 17 options that work for some inputs and announce themselves for the rest.
|
||||
|
||||
## The queue
|
||||
|
||||
Each row is one unit of work: implement it, delete its entry from the ledger,
|
||||
add a test proving the option changes what the code does, and regenerate.
|
||||
|
||||
### `AgentTeam.__init__` (15)
|
||||
### `AgentTeam.__init__` (8)
|
||||
|
||||
- `managerLlm`
|
||||
- `memory`
|
||||
- `planning`
|
||||
- `context`
|
||||
- `execution`
|
||||
- `hooks`
|
||||
- `autonomy`
|
||||
- `knowledge`
|
||||
- `guardrails`
|
||||
@@ -45,7 +39,6 @@ add a test proving the option changes what the code does, and regenerate.
|
||||
- `caching`
|
||||
- `learn`
|
||||
- `toolsRunOn`
|
||||
- `runOn`
|
||||
|
||||
### `Handoff` (8)
|
||||
|
||||
@@ -58,40 +51,12 @@ add a test proving the option changes what the code does, and regenerate.
|
||||
- `detectCycles`
|
||||
- `maxDepth`
|
||||
|
||||
### `Task.__init__` (32)
|
||||
### `Task.__init__` (4)
|
||||
|
||||
- `asyncExecution`
|
||||
- `config`
|
||||
- `outputPydantic`
|
||||
- `images`
|
||||
- `nextTasks`
|
||||
- `condition`
|
||||
- `isStart`
|
||||
- `loopState`
|
||||
- `memory`
|
||||
- `inputFile`
|
||||
- `rerun`
|
||||
- `retainFullContext`
|
||||
- `agentConfig`
|
||||
- `skipOnFailure`
|
||||
- `retryDelay`
|
||||
- `handler`
|
||||
- `loopOver`
|
||||
- `loopVar`
|
||||
- `execution`
|
||||
- `routing`
|
||||
- `outputConfig`
|
||||
- `when`
|
||||
- `thenTask`
|
||||
- `elseTask`
|
||||
- `autonomy`
|
||||
- `knowledge`
|
||||
- `web`
|
||||
- `reflection`
|
||||
- `planning`
|
||||
- `hooks`
|
||||
- `caching`
|
||||
- `failOnMemoryError`
|
||||
|
||||
## Partial
|
||||
|
||||
@@ -107,5 +72,10 @@ add a test proving the option changes what the code does, and regenerate.
|
||||
| `Agent.chat` | `attachments` | `src/praisonai-ts/src/agent/simple.ts` |
|
||||
| `Agent.chat` | `outputPydantic` | `src/praisonai-ts/src/agent/simple.ts` |
|
||||
| `Agent.chat` | `seed` | `src/praisonai-ts/src/agent/simple.ts` |
|
||||
| `AgentTeam` | `context` | `src/praisonai-ts/src/agent/team-options.ts` |
|
||||
| `AgentTeam` | `execution` | `src/praisonai-ts/src/agent/team-options.ts` |
|
||||
| `AgentTeam` | `hooks` | `src/praisonai-ts/src/agent/team-options.ts` |
|
||||
| `AgentTeam` | `memory` | `src/praisonai-ts/src/agent/team-memory.ts` |
|
||||
| `AgentTeam` | `planning` | `src/praisonai-ts/src/agent/team-planning.ts` |
|
||||
| `ChromaMemory` | `ragDbPath` | `src/praisonai-ts/src/memory/adapters.ts` |
|
||||
| `Task` | `guardrails` | `src/praisonai-ts/src/agent/types.ts` |
|
||||
@@ -95,7 +95,7 @@ TS-only members: `config`, `pretty`?, `fetch`?, `outputSchema`?, `outputSchemaNa
|
||||
### `AgentTeam.__init__`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agents/agents.py:648`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/team.ts:17` (ctor `src/praisonai-ts/src/agent/team.ts:210`)
|
||||
- TypeScript: `src/praisonai-ts/src/agent/team.ts:87` (ctor `src/praisonai-ts/src/agent/team.ts:373`)
|
||||
- Python aliases: PraisonAIAgents, Agents
|
||||
- Counts: 23 python params: 20 exact, 3 camelCase, 0 alias, 0 flattened, 0 missing; 1 mismatches; 1 waived; 3 TS-only of 26
|
||||
|
||||
@@ -109,12 +109,12 @@ TS-only members: `config`, `pretty`?, `fetch`?, `outputSchema`?, `outputSchemaNa
|
||||
| `variables` | positional | null | `Optional[Dict[str, Any]]` | exact | `variables` | undefined | `Record<string, unknown>` | ok |
|
||||
| `llm` | positional | null | `Optional[str]` | exact | `llm` | undefined | `string` | ok |
|
||||
| `model` | positional | null | `Optional[str]` | exact | `model` | undefined | `string` | ok |
|
||||
| `memory` | positional | false | `Optional[Any]` | exact | `memory` | undefined | `unknown` | ok |
|
||||
| `planning` | positional | false | `Optional[Any]` | exact | `planning` | undefined | `unknown` | ok |
|
||||
| `context` | positional | false | `Optional[Any]` | exact | `context` | undefined | `unknown` | ok |
|
||||
| `memory` | positional | false | `Optional[Any]` | exact | `memory` | undefined | `TeamMemoryInput` | ok |
|
||||
| `planning` | positional | false | `Optional[Any]` | exact | `planning` | undefined | `TeamPlanningInput` | ok |
|
||||
| `context` | positional | false | `Optional[Any]` | exact | `context` | undefined | `TeamContextInput` | ok |
|
||||
| `output` | positional | null | `Optional[Any]` | exact | `output` | undefined | `unknown` | ok |
|
||||
| `execution` | positional | null | `Optional[Any]` | exact | `execution` | undefined | `unknown` | ok |
|
||||
| `hooks` | positional | null | `Optional[Any]` | exact | `hooks` | undefined | `unknown` | ok |
|
||||
| `execution` | positional | null | `Optional[Any]` | exact | `execution` | undefined | `TeamExecutionInput` | ok |
|
||||
| `hooks` | positional | null | `Optional[Any]` | exact | `hooks` | undefined | `TeamHooksInput` | ok |
|
||||
| `autonomy` | positional | null | `Optional[Any]` | exact | `autonomy` | undefined | `unknown` | ok |
|
||||
| `knowledge` | positional | null | `Optional[Any]` | exact | `knowledge` | undefined | `unknown` | ok |
|
||||
| `guardrails` | positional | null | `Optional[Any]` | exact | `guardrails` | undefined | `unknown` | ok |
|
||||
@@ -130,7 +130,7 @@ TS-only members: `configOrAgents`, `verbose`?, `pretty`?
|
||||
### `Task.__init__`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/task/task.py:48`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/types.ts:64` (ctor `src/praisonai-ts/src/agent/types.ts:322`)
|
||||
- TypeScript: `src/praisonai-ts/src/agent/types.ts:115` (ctor `src/praisonai-ts/src/agent/types.ts:379`)
|
||||
- Counts: 60 python params: 31 exact, 29 camelCase, 0 alias, 0 flattened, 0 missing; 7 mismatches; 7 waived; 1 TS-only of 62
|
||||
|
||||
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|
||||
@@ -213,7 +213,7 @@ TS-only members: `previousResult`?, `onToken`?, `signal`?, `onEvent`?, `options`
|
||||
### `Agent.chat`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agent/chat_mixin.py:3101`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:3338`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/simple.ts:3344`
|
||||
- Counts: 17 python params: 7 exact, 9 camelCase, 1 alias, 0 flattened, 0 missing; 0 mismatches; 0 waived; 2 TS-only of 19
|
||||
|
||||
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|
||||
@@ -241,7 +241,7 @@ TS-only members: `previousResult`?, `options`?
|
||||
### `AgentTeam.start`
|
||||
|
||||
- Python: `src/praisonai-agents/praisonaiagents/agents/agents.py:2007`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/team.ts:383`
|
||||
- TypeScript: `src/praisonai-ts/src/agent/team.ts:1072`
|
||||
- Counts: 3 python params: 2 exact, 1 camelCase, 0 alias, 0 flattened, 0 missing; 0 mismatches; 0 waived; 1 TS-only of 4
|
||||
|
||||
| Python param | Kind | Py default | Py type | Match | TS name | TS default | TS type | Status |
|
||||
|
||||
@@ -50,6 +50,31 @@
|
||||
"option": "seed",
|
||||
"surface": "Agent.chat"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/agent/team-options.ts",
|
||||
"option": "context",
|
||||
"surface": "AgentTeam"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/agent/team-options.ts",
|
||||
"option": "execution",
|
||||
"surface": "AgentTeam"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/agent/team-options.ts",
|
||||
"option": "hooks",
|
||||
"surface": "AgentTeam"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/agent/team-memory.ts",
|
||||
"option": "memory",
|
||||
"surface": "AgentTeam"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/agent/team-planning.ts",
|
||||
"option": "planning",
|
||||
"surface": "AgentTeam"
|
||||
},
|
||||
{
|
||||
"file": "src/praisonai-ts/src/memory/adapters.ts",
|
||||
"option": "ragDbPath",
|
||||
@@ -65,17 +90,10 @@
|
||||
"AgentTeam.__init__": [
|
||||
"autonomy",
|
||||
"caching",
|
||||
"context",
|
||||
"execution",
|
||||
"guardrails",
|
||||
"hooks",
|
||||
"knowledge",
|
||||
"learn",
|
||||
"managerLlm",
|
||||
"memory",
|
||||
"planning",
|
||||
"reflection",
|
||||
"runOn",
|
||||
"toolsRunOn",
|
||||
"web"
|
||||
],
|
||||
@@ -90,39 +108,11 @@
|
||||
"timeoutSeconds"
|
||||
],
|
||||
"Task.__init__": [
|
||||
"agentConfig",
|
||||
"asyncExecution",
|
||||
"autonomy",
|
||||
"caching",
|
||||
"condition",
|
||||
"config",
|
||||
"elseTask",
|
||||
"execution",
|
||||
"failOnMemoryError",
|
||||
"handler",
|
||||
"hooks",
|
||||
"images",
|
||||
"inputFile",
|
||||
"isStart",
|
||||
"knowledge",
|
||||
"loopOver",
|
||||
"loopState",
|
||||
"loopVar",
|
||||
"memory",
|
||||
"nextTasks",
|
||||
"outputConfig",
|
||||
"outputPydantic",
|
||||
"planning",
|
||||
"reflection",
|
||||
"rerun",
|
||||
"retainFullContext",
|
||||
"retryDelay",
|
||||
"routing",
|
||||
"skipOnFailure",
|
||||
"thenTask",
|
||||
"web",
|
||||
"when"
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"total": 55
|
||||
"total": 20
|
||||
}
|
||||
@@ -1692,7 +1692,7 @@
|
||||
}
|
||||
],
|
||||
"typescript": {
|
||||
"location": "src/praisonai-ts/src/agent/simple.ts:3338",
|
||||
"location": "src/praisonai-ts/src/agent/simple.ts:3344",
|
||||
"options_interfaces": [
|
||||
"options: AgentChatOptions"
|
||||
],
|
||||
@@ -2186,8 +2186,8 @@
|
||||
"kind": "property",
|
||||
"name": "memory",
|
||||
"required": false,
|
||||
"type_class": "unknown",
|
||||
"type_text": "unknown"
|
||||
"type_class": "object",
|
||||
"type_text": "TeamMemoryInput"
|
||||
},
|
||||
"ts_name": "memory",
|
||||
"type_class": "unknown",
|
||||
@@ -2211,8 +2211,8 @@
|
||||
"kind": "property",
|
||||
"name": "planning",
|
||||
"required": false,
|
||||
"type_class": "unknown",
|
||||
"type_text": "unknown"
|
||||
"type_class": "object",
|
||||
"type_text": "TeamPlanningInput"
|
||||
},
|
||||
"ts_name": "planning",
|
||||
"type_class": "unknown",
|
||||
@@ -2236,8 +2236,8 @@
|
||||
"kind": "property",
|
||||
"name": "context",
|
||||
"required": false,
|
||||
"type_class": "unknown",
|
||||
"type_text": "unknown"
|
||||
"type_class": "object",
|
||||
"type_text": "TeamContextInput"
|
||||
},
|
||||
"ts_name": "context",
|
||||
"type_class": "unknown",
|
||||
@@ -2286,8 +2286,8 @@
|
||||
"kind": "property",
|
||||
"name": "execution",
|
||||
"required": false,
|
||||
"type_class": "unknown",
|
||||
"type_text": "unknown"
|
||||
"type_class": "object",
|
||||
"type_text": "TeamExecutionInput"
|
||||
},
|
||||
"ts_name": "execution",
|
||||
"type_class": "unknown",
|
||||
@@ -2311,8 +2311,8 @@
|
||||
"kind": "property",
|
||||
"name": "hooks",
|
||||
"required": false,
|
||||
"type_class": "unknown",
|
||||
"type_text": "unknown"
|
||||
"type_class": "object",
|
||||
"type_text": "TeamHooksInput"
|
||||
},
|
||||
"ts_name": "hooks",
|
||||
"type_class": "unknown",
|
||||
@@ -2587,8 +2587,8 @@
|
||||
}
|
||||
],
|
||||
"typescript": {
|
||||
"ctor_location": "src/praisonai-ts/src/agent/team.ts:210",
|
||||
"location": "src/praisonai-ts/src/agent/team.ts:17"
|
||||
"ctor_location": "src/praisonai-ts/src/agent/team.ts:373",
|
||||
"location": "src/praisonai-ts/src/agent/team.ts:87"
|
||||
}
|
||||
},
|
||||
"AgentTeam.start": {
|
||||
@@ -2698,7 +2698,7 @@
|
||||
}
|
||||
],
|
||||
"typescript": {
|
||||
"location": "src/praisonai-ts/src/agent/team.ts:383",
|
||||
"location": "src/praisonai-ts/src/agent/team.ts:1072",
|
||||
"options_interfaces": [
|
||||
"options: AgentTeamStartOptions"
|
||||
],
|
||||
@@ -6434,8 +6434,8 @@
|
||||
}
|
||||
],
|
||||
"typescript": {
|
||||
"ctor_location": "src/praisonai-ts/src/agent/types.ts:322",
|
||||
"location": "src/praisonai-ts/src/agent/types.ts:64"
|
||||
"ctor_location": "src/praisonai-ts/src/agent/types.ts:379",
|
||||
"location": "src/praisonai-ts/src/agent/types.ts:115"
|
||||
}
|
||||
},
|
||||
"ToolsetRegistry.register_toolset": {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* The hierarchical manager loop: `AgentTeam({ process: 'hierarchical', managerLlm })`.
|
||||
*
|
||||
* Python parity: `praisonaiagents/process/process.py::Process.hierarchical`. A
|
||||
* synthetic "Manager" agent, running on `manager_llm`, is shown the status of
|
||||
* every real task and answers with `{task_id, agent_name, action}`. The team
|
||||
* executes what it names, re-prompts on an invalid pick, and stops on
|
||||
* `action: "stop"`, on max iterations, or when every task is accounted for.
|
||||
*
|
||||
* The bounds are Python's, and they exist because each one was a hang: a task
|
||||
* that ends "failed" is a valid id that never becomes "completed", so without
|
||||
* the per-task re-selection cap the manager can re-delegate it forever.
|
||||
*/
|
||||
|
||||
import { Logger } from '../utils/logger';
|
||||
import type { TeamTaskStatus } from './team-options';
|
||||
|
||||
/** MAX invalid selections before the loop gives up (Python: MAX_INVALID_SELECTIONS). */
|
||||
const MAX_INVALID_SELECTIONS = 3;
|
||||
/** MAX times one failed task may be re-delegated (Python: MAX_TASK_RESELECTIONS). */
|
||||
const MAX_TASK_RESELECTIONS = 3;
|
||||
|
||||
/** What the manager is told about one task. */
|
||||
export interface ManagerTaskView {
|
||||
task_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: TeamTaskStatus;
|
||||
agent: string;
|
||||
}
|
||||
|
||||
/** The manager's answer. Python parity: ManagerInstructions. */
|
||||
export interface ManagerInstructions {
|
||||
task_id: number;
|
||||
agent_name: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** JSON schema pinned on the manager's reply. Python parity: output_pydantic=ManagerInstructions. */
|
||||
export const MANAGER_INSTRUCTIONS_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task_id: { type: 'integer' },
|
||||
agent_name: { type: 'string' },
|
||||
action: { type: 'string', enum: ['execute', 'stop'] },
|
||||
},
|
||||
required: ['task_id', 'agent_name', 'action'],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
/** Everything the loop needs from the team, so this module never touches team state directly. */
|
||||
export interface HierarchicalOptions {
|
||||
/** Model the Manager agent runs on. Python parity: manager_llm. */
|
||||
managerLlm: string;
|
||||
/** Iteration ceiling. Python parity: max_iter. */
|
||||
maxIter: number;
|
||||
verbose: boolean;
|
||||
/** A fresh view of every delegable task, read once per iteration. */
|
||||
snapshot: () => ManagerTaskView[];
|
||||
/** Names of the agents the manager may delegate to. */
|
||||
agentNames: string[];
|
||||
/** Point a task at a different agent, by the name the manager chose. */
|
||||
assignAgent: (taskId: number, agentName: string) => void;
|
||||
/** Run one task and leave its status updated. */
|
||||
runTask: (taskId: number) => Promise<void>;
|
||||
/**
|
||||
* Ask the manager. Defaults to a "Manager" Agent on `managerLlm`; injectable
|
||||
* so a caller (or a test) can supply the decision without a model.
|
||||
*/
|
||||
ask?: (prompt: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Why the loop stopped. Useful to callers and to tests. */
|
||||
export type HierarchicalOutcome = 'completed' | 'stopped' | 'max-iterations' | 'invalid-selections' | 'manager-error';
|
||||
|
||||
/** Build the Manager agent Python creates for a hierarchical run. */
|
||||
async function createManagerAsk(managerLlm: string, verbose: boolean): Promise<(prompt: string) => Promise<string>> {
|
||||
const { Agent } = await import('./simple');
|
||||
const manager = new Agent({
|
||||
name: 'Manager',
|
||||
role: 'Project manager',
|
||||
goal: 'Manage the entire flow of tasks and delegate them to the right agent',
|
||||
backstory: 'Expert project manager to coordinate tasks among agents',
|
||||
instructions: 'Decide the order of tasks and which agent executes them',
|
||||
llm: managerLlm,
|
||||
verbose,
|
||||
markdown: true,
|
||||
stream: false,
|
||||
});
|
||||
return (prompt: string) => manager.chat(prompt, undefined, undefined, { outputJson: MANAGER_INSTRUCTIONS_SCHEMA as unknown as Record<string, any> });
|
||||
}
|
||||
|
||||
/** Pull the manager's JSON out of a reply that may be fenced or padded with prose. */
|
||||
export function parseManagerInstructions(reply: string): ManagerInstructions | null {
|
||||
const unfenced = reply.replace(/```(?:json)?/gi, '').trim();
|
||||
const candidates = [unfenced];
|
||||
const firstBrace = unfenced.indexOf('{');
|
||||
const lastBrace = unfenced.lastIndexOf('}');
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) candidates.push(unfenced.slice(firstBrace, lastBrace + 1));
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
const taskId = Number((parsed as any).task_id ?? (parsed as any).taskId);
|
||||
const action = String((parsed as any).action ?? 'execute');
|
||||
const agentName = String((parsed as any).agent_name ?? (parsed as any).agentName ?? '');
|
||||
if (!Number.isFinite(taskId) && action.toLowerCase() !== 'stop') continue;
|
||||
return { task_id: taskId, agent_name: agentName, action };
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the tasks under manager supervision. Returns why the loop ended; task
|
||||
* results are left on the team through the `runTask` callback.
|
||||
*/
|
||||
export async function runHierarchical(options: HierarchicalOptions): Promise<HierarchicalOutcome> {
|
||||
const ask = options.ask ?? (await createManagerAsk(options.managerLlm, options.verbose));
|
||||
|
||||
const excluded = new Set<number>();
|
||||
const counted = new Set<number>();
|
||||
const failedReselects = new Map<number, number>();
|
||||
const initial = options.snapshot();
|
||||
const total = initial.length;
|
||||
for (const view of initial) if (view.status === 'completed') counted.add(view.task_id);
|
||||
|
||||
let invalidSelections = 0;
|
||||
let errorContext = '';
|
||||
let iteration = 0;
|
||||
|
||||
while (counted.size < total) {
|
||||
iteration += 1;
|
||||
if (iteration > options.maxIter) {
|
||||
await Logger.debug(`Max iteration limit ${options.maxIter} reached, ending hierarchical process.`);
|
||||
return 'max-iterations';
|
||||
}
|
||||
|
||||
const summary = options.snapshot().filter((view) => !excluded.has(view.task_id));
|
||||
const prompt = `
|
||||
Here is the current status of all tasks except yours (manager_task):
|
||||
${JSON.stringify(summary, null, 2)}
|
||||
|
||||
Provide a JSON with the structure:
|
||||
{
|
||||
"task_id": <int>,
|
||||
"agent_name": "<string>",
|
||||
"action": "<execute or stop>"
|
||||
}
|
||||
` + errorContext;
|
||||
|
||||
let instructions: ManagerInstructions | null;
|
||||
try {
|
||||
instructions = parseManagerInstructions(await ask(prompt));
|
||||
} catch (error) {
|
||||
await Logger.error(`Manager parse error: ${(error as Error)?.message ?? String(error)}`);
|
||||
return 'manager-error';
|
||||
}
|
||||
if (!instructions) {
|
||||
await Logger.error('Manager parse error: the reply was not the requested JSON.');
|
||||
return 'manager-error';
|
||||
}
|
||||
|
||||
if (instructions.action.toLowerCase() === 'stop') {
|
||||
await Logger.debug('Manager decided to stop task execution');
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
const selected = instructions.task_id;
|
||||
const known = summary.some((view) => view.task_id === selected);
|
||||
if (!known) {
|
||||
invalidSelections += 1;
|
||||
if (invalidSelections > MAX_INVALID_SELECTIONS) {
|
||||
await Logger.error(`Manager produced ${invalidSelections} invalid task selections; aborting.`);
|
||||
return 'invalid-selections';
|
||||
}
|
||||
const validIds = summary.map((view) => view.task_id);
|
||||
errorContext =
|
||||
`\n\n[ERROR] Your previous selection of task_id=${selected} was invalid. ` +
|
||||
`Valid task IDs are: ${JSON.stringify(validIds)}. Never select manager_task. ` +
|
||||
`Please select again from the valid options.`;
|
||||
continue;
|
||||
}
|
||||
|
||||
invalidSelections = 0;
|
||||
errorContext = '';
|
||||
|
||||
if (instructions.agent_name && options.agentNames.includes(instructions.agent_name)) {
|
||||
options.assignAgent(selected, instructions.agent_name);
|
||||
}
|
||||
|
||||
const before = summary.find((view) => view.task_id === selected)!;
|
||||
if (before.status === 'failed') {
|
||||
const seen = (failedReselects.get(selected) ?? 0) + 1;
|
||||
failedReselects.set(selected, seen);
|
||||
if (seen > MAX_TASK_RESELECTIONS) {
|
||||
await Logger.error(`Task ${selected} failed ${seen} times; excluding from further delegation.`);
|
||||
excluded.add(selected);
|
||||
counted.add(selected);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (before.status === 'completed') {
|
||||
await Logger.warn(`Manager re-selected already-completed task ${selected}; ignoring re-selection.`);
|
||||
counted.add(selected);
|
||||
continue;
|
||||
}
|
||||
|
||||
await options.runTask(selected);
|
||||
const after = options.snapshot().find((view) => view.task_id === selected);
|
||||
if (after?.status === 'completed') counted.add(selected);
|
||||
}
|
||||
|
||||
return 'completed';
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The shared memory an AgentTeam holds on behalf of its tasks.
|
||||
*
|
||||
* Python parity: `AgentTeam(memory=...)` builds one `Memory` for the whole team
|
||||
* and assigns it to every task that did not bring its own. The effect on a run
|
||||
* is in `_prepare_task_prompt`: retrieved memory is appended to the task prompt
|
||||
* (bare bullet lines, no section headers), and each finished task writes its
|
||||
* result back. This module is that effect, held by the team rather than pushed
|
||||
* into member agents -- an Agent's own `memory=` stays its own.
|
||||
*/
|
||||
|
||||
import { Logger } from '../utils/logger';
|
||||
import { Memory, type MemoryConfig } from '../memory/memory';
|
||||
import { notYetHonoured } from '../utils/parity-notice';
|
||||
|
||||
/** The minimum a team memory store must do. Matches `Agent`'s memory contract. */
|
||||
export interface TeamMemoryStore {
|
||||
search(query: string, limit?: number): Promise<Array<{ entry: { content: string; role: string; metadata?: Record<string, any> }; score: number }>>;
|
||||
add(content: string, role: 'user' | 'assistant' | 'system', metadata?: Record<string, any>): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The value `AgentTeam({ memory })` accepts. */
|
||||
export type TeamMemoryInput =
|
||||
| boolean
|
||||
| string
|
||||
| MemoryConfig
|
||||
| TeamMemoryStore
|
||||
| { userId?: string; user_id?: string; config?: MemoryConfig; [key: string]: unknown };
|
||||
|
||||
/** What {@link resolveTeamMemory} works out from the `memory` option. */
|
||||
export interface TeamMemorySettings {
|
||||
/** Python parity: AgentTeam.shared_memory. Undefined when memory is off. */
|
||||
store?: TeamMemoryStore;
|
||||
/** A memory file to open lazily (needs fs, so it is deferred to first use). */
|
||||
file?: string;
|
||||
/** Python parity: AgentTeam.user_id (default "praison"). */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function looksLikeStore(value: unknown): value is TeamMemoryStore {
|
||||
const store = value as TeamMemoryStore | undefined;
|
||||
return !!store && typeof store.search === 'function' && typeof store.add === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `memory` into a shared store, mirroring what Python's resolver
|
||||
* accepts: `False`/`None` off, `True` on with defaults, a config, a memory
|
||||
* instance, or a `MultiAgentMemoryConfig`-shaped object carrying `user_id`.
|
||||
*/
|
||||
export function resolveTeamMemory(value: unknown): TeamMemorySettings {
|
||||
const off: TeamMemorySettings = { userId: 'praison' };
|
||||
if (value === undefined || value === null || value === false) return off;
|
||||
if (value === true) return { store: new Memory(), userId: 'praison' };
|
||||
|
||||
if (typeof value === 'string' && /[\/\\]|\.jsonl?$/.test(value)) return { file: value, userId: 'praison' };
|
||||
|
||||
if (looksLikeStore(value)) return { store: value, userId: 'praison' };
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const source = value as Record<string, unknown>;
|
||||
const userId = typeof source.userId === 'string' ? source.userId
|
||||
: typeof source.user_id === 'string' ? source.user_id
|
||||
: 'praison';
|
||||
const inner = source.config;
|
||||
if (looksLikeStore(inner)) return { store: inner, userId };
|
||||
const config = (inner && typeof inner === 'object' ? inner : source) as MemoryConfig;
|
||||
return { store: new Memory(config), userId };
|
||||
}
|
||||
|
||||
const unusable = typeof value === 'string'
|
||||
? `Provider preset ${JSON.stringify(value)} is not available`
|
||||
: `A ${typeof value} is not a memory config`;
|
||||
notYetHonoured('AgentTeam', 'memory', `${unusable}; pass true, a MemoryConfig, a file path or a memory instance.`);
|
||||
return off;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team's shared memory: recall before a task, remember after it.
|
||||
*
|
||||
* Created only when `memory` is truthy, so a team without it does no work and
|
||||
* allocates no store.
|
||||
*/
|
||||
export class TeamMemory {
|
||||
private store?: TeamMemoryStore;
|
||||
private readonly file?: string;
|
||||
/** Python parity: AgentTeam.user_id. */
|
||||
readonly userId: string;
|
||||
|
||||
constructor(settings: TeamMemorySettings) {
|
||||
this.store = settings.store;
|
||||
this.file = settings.file;
|
||||
this.userId = settings.userId;
|
||||
}
|
||||
|
||||
/** The underlying store (Python parity: `team.shared_memory`), if open. */
|
||||
getStore(): TeamMemoryStore | undefined {
|
||||
return this.store;
|
||||
}
|
||||
|
||||
private async ensureStore(): Promise<TeamMemoryStore | undefined> {
|
||||
if (!this.store && this.file) {
|
||||
const { FileMemory } = await import('../memory/file-memory');
|
||||
const store = new FileMemory({ filePath: this.file });
|
||||
await store.initialize();
|
||||
this.store = store as unknown as TeamMemoryStore;
|
||||
}
|
||||
return this.store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory relevant to `prompt`, formatted the way Python appends it to a task
|
||||
* prompt: bare bullet lines, no section header. Empty string when nothing is
|
||||
* recalled, so the caller can leave the prompt untouched.
|
||||
*/
|
||||
async recall(prompt: string, limit: number = 3): Promise<string> {
|
||||
const store = await this.ensureStore();
|
||||
if (!store) return '';
|
||||
try {
|
||||
// Search wide, then keep only this team's own entries. A store shared
|
||||
// across teams/users tags each write with its userId (see `remember`);
|
||||
// untagged entries predate scoping and stay visible. Without this filter
|
||||
// a reused store leaks one user's memories into another's prompt.
|
||||
const hits = await store.search(prompt, Math.max(limit, 10));
|
||||
const lines = hits
|
||||
.filter((hit) => this.ownedByTeam(hit?.entry?.metadata))
|
||||
.map((hit) => (hit?.entry?.content ?? '').trim())
|
||||
.filter((content) => content.length > 0)
|
||||
.slice(0, limit)
|
||||
.map((content) => `• ${content}`);
|
||||
return lines.join('\n');
|
||||
} catch (error) {
|
||||
await Logger.warn('Team memory recall failed:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** An entry belongs to this team when its userId matches, or it carries none. */
|
||||
private ownedByTeam(metadata?: Record<string, any>): boolean {
|
||||
const owner = metadata?.userId ?? metadata?.user_id;
|
||||
return owner === undefined || owner === null || owner === this.userId;
|
||||
}
|
||||
|
||||
/** Write a finished task back to the shared store. */
|
||||
async remember(prompt: string, result: string, taskName: string): Promise<void> {
|
||||
const store = await this.ensureStore();
|
||||
if (!store) return;
|
||||
try {
|
||||
await store.add(prompt, 'user', { task: taskName, userId: this.userId });
|
||||
if (result) await store.add(result, 'assistant', { task: taskName, userId: this.userId });
|
||||
} catch (error) {
|
||||
await Logger.warn('Team memory write failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the team's shared memory, or undefined when `memory` is off. */
|
||||
export function createTeamMemory(value: unknown): { memory?: TeamMemory; userId: string } {
|
||||
const settings = resolveTeamMemory(value);
|
||||
if (!settings.store && !settings.file) return { userId: settings.userId };
|
||||
return { memory: new TeamMemory(settings), userId: settings.userId };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Resolvers for AgentTeam's consolidated feature parameters.
|
||||
*
|
||||
* Python's AgentTeam funnels `execution`, `hooks`, `planning`, `memory` and
|
||||
* `context` through one canonical resolver (`praisonaiagents/config/param_resolver.py`)
|
||||
* with a per-parameter preset table. These helpers reproduce the shapes that
|
||||
* resolver accepts for the team surface -- preset string, `[preset, overrides]`
|
||||
* pair, plain object, instance -- so a team option means the same thing in both
|
||||
* SDKs. Everything here is pure: no I/O, no model calls.
|
||||
*/
|
||||
|
||||
import { ContextManager, type ContextManagerConfig } from '../context/manager';
|
||||
import { notYetHonoured } from '../utils/parity-notice';
|
||||
import type { Task } from './types';
|
||||
|
||||
/** Lifecycle status of a task inside a team run (Python parity: Task.status). */
|
||||
export type TeamTaskStatus = 'not started' | 'in progress' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* What a team hook is handed. When the team was given a {@link Task} object the
|
||||
* `task` field carries it; a plain-string task still gets a name, a description
|
||||
* and a live status.
|
||||
*/
|
||||
export interface TeamTaskRef {
|
||||
/** The Task's name, else `task_<n>`. */
|
||||
name: string;
|
||||
/** The prompt this task runs (a Task's description). */
|
||||
description: string;
|
||||
/** Where the task is in its lifecycle when the hook fires. */
|
||||
status: TeamTaskStatus;
|
||||
/** Name of the agent that ran it, once one has been chosen. */
|
||||
agent?: string;
|
||||
/** The originating Task, when the team was given one. */
|
||||
task?: Task;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// execution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Python parity: MultiAgentExecutionConfig. */
|
||||
export interface TeamExecutionSettings {
|
||||
/** Maximum manager iterations in a hierarchical run. */
|
||||
maxIter: number;
|
||||
/** Maximum attempts per task before it is left failed. */
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
/** Python parity: MULTI_AGENT_EXECUTION_PRESETS. */
|
||||
export const MULTI_AGENT_EXECUTION_PRESETS: Readonly<Record<string, TeamExecutionSettings>> = {
|
||||
fast: { maxIter: 5, maxRetries: 2 },
|
||||
balanced: { maxIter: 10, maxRetries: 5 },
|
||||
thorough: { maxIter: 20, maxRetries: 5 },
|
||||
unlimited: { maxIter: 100, maxRetries: 10 },
|
||||
};
|
||||
|
||||
/** The value `AgentTeam({ execution })` accepts. */
|
||||
export type TeamExecutionInput =
|
||||
| string
|
||||
| Partial<TeamExecutionSettings>
|
||||
| [string, Partial<TeamExecutionSettings>];
|
||||
|
||||
const DEFAULT_EXECUTION: TeamExecutionSettings = { maxIter: 10, maxRetries: 5 };
|
||||
|
||||
function numberField(source: Record<string, unknown>, ...names: string[]): number | undefined {
|
||||
for (const name of names) {
|
||||
const value = source[name];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `execution` into iteration and retry limits.
|
||||
*
|
||||
* Python parity: the `MULTI_AGENT_EXECUTION_PRESETS` table, `[preset, overrides]`
|
||||
* merging, and the floor that lifts `max_retries` to 3.
|
||||
*/
|
||||
export function resolveTeamExecution(value: unknown): TeamExecutionSettings {
|
||||
let resolved: TeamExecutionSettings = { ...DEFAULT_EXECUTION };
|
||||
|
||||
const applyPreset = (name: string): boolean => {
|
||||
const preset = MULTI_AGENT_EXECUTION_PRESETS[name];
|
||||
if (!preset) {
|
||||
notYetHonoured('AgentTeam', 'execution', `Preset ${JSON.stringify(name)} is not recognised (${Object.keys(MULTI_AGENT_EXECUTION_PRESETS).join(' | ')}).`);
|
||||
return false;
|
||||
}
|
||||
resolved = { ...preset };
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyOverrides = (overrides: Record<string, unknown>): void => {
|
||||
const maxIter = numberField(overrides, 'maxIter', 'max_iter');
|
||||
const maxRetries = numberField(overrides, 'maxRetries', 'max_retries');
|
||||
if (maxIter !== undefined) resolved.maxIter = maxIter;
|
||||
if (maxRetries !== undefined) resolved.maxRetries = maxRetries;
|
||||
};
|
||||
|
||||
if (typeof value === 'string') {
|
||||
applyPreset(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
const [preset, overrides] = value as [unknown, unknown];
|
||||
if (typeof preset === 'string') applyPreset(preset);
|
||||
if (overrides && typeof overrides === 'object') applyOverrides(overrides as Record<string, unknown>);
|
||||
} else if (value && typeof value === 'object') {
|
||||
applyOverrides(value as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Python: `if _max_retries < 3: _max_retries = 3` -- a team never gives up
|
||||
// after fewer than three attempts, whatever the preset said.
|
||||
if (resolved.maxRetries < 3) resolved.maxRetries = 3;
|
||||
if (resolved.maxIter < 1) resolved.maxIter = 1;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// hooks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fired before a task runs. Python parity: MultiAgentHooksConfig.on_task_start. */
|
||||
export type TeamTaskStartHook = (task: TeamTaskRef, taskId: number) => unknown | Promise<unknown>;
|
||||
/** Fired after a task completes. Python parity: MultiAgentHooksConfig.on_task_complete. */
|
||||
export type TeamTaskCompleteHook = (task: TeamTaskRef, output: unknown) => unknown | Promise<unknown>;
|
||||
/**
|
||||
* Decides whether an agent's answer finishes the task. Returning false drives
|
||||
* the retry loop. Python parity: MultiAgentHooksConfig.completion_checker.
|
||||
*/
|
||||
export type TeamCompletionChecker = (task: TeamTaskRef, agentOutput: string) => boolean;
|
||||
|
||||
/** Python parity: MultiAgentHooksConfig. */
|
||||
export interface TeamHooksSettings {
|
||||
onTaskStart?: TeamTaskStartHook;
|
||||
onTaskComplete?: TeamTaskCompleteHook;
|
||||
completionChecker?: TeamCompletionChecker;
|
||||
}
|
||||
|
||||
/** The value `AgentTeam({ hooks })` accepts (snake_case keys are accepted too). */
|
||||
export type TeamHooksInput = TeamHooksSettings & Record<string, unknown>;
|
||||
|
||||
function functionField(source: Record<string, unknown>, ...names: string[]): any {
|
||||
for (const name of names) {
|
||||
const value = source[name];
|
||||
if (typeof value === 'function') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const HOOKS_HELP = 'Pass an object with onTaskStart, onTaskComplete and/or completionChecker.';
|
||||
|
||||
/** Resolve `hooks` into the three team lifecycle callbacks Python extracts. */
|
||||
export function resolveTeamHooks(value: unknown): TeamHooksSettings {
|
||||
if (value === undefined || value === null || value === false) return {};
|
||||
let unusable: string | undefined;
|
||||
let settings: TeamHooksSettings = {};
|
||||
if (typeof value !== 'object') {
|
||||
unusable = `A ${typeof value} is not a hooks config. ${HOOKS_HELP}`;
|
||||
} else {
|
||||
const source = value as Record<string, unknown>;
|
||||
settings = {
|
||||
onTaskStart: functionField(source, 'onTaskStart', 'on_task_start'),
|
||||
onTaskComplete: functionField(source, 'onTaskComplete', 'on_task_complete'),
|
||||
completionChecker: functionField(source, 'completionChecker', 'completion_checker'),
|
||||
};
|
||||
if (!settings.onTaskStart && !settings.onTaskComplete && !settings.completionChecker && Object.keys(source).length > 0) {
|
||||
unusable = `No team hook was found on the value given. ${HOOKS_HELP}`;
|
||||
}
|
||||
}
|
||||
if (unusable) notYetHonoured('AgentTeam', 'hooks', unusable);
|
||||
return settings;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// context
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The value `AgentTeam({ context })` accepts. */
|
||||
export type TeamContextInput = boolean | string | ContextManagerConfig | ContextManager;
|
||||
|
||||
/**
|
||||
* Resolve `context` into the team's shared ContextManager.
|
||||
*
|
||||
* Python parity: `AgentTeam.context_manager` -- `False`/`None` means zero
|
||||
* overhead and no manager, `True` builds one with defaults, a config builds one
|
||||
* from that config, and a manager instance is used as given.
|
||||
*/
|
||||
export function resolveTeamContext(value: unknown): ContextManager | undefined {
|
||||
if (value === undefined || value === null || value === false) return undefined;
|
||||
if (value instanceof ContextManager) return value;
|
||||
if (value === true) return new ContextManager({});
|
||||
if (typeof value === 'object') return new ContextManager(value as ContextManagerConfig);
|
||||
const unusable = typeof value === 'string'
|
||||
? `Preset ${JSON.stringify(value)} is not available`
|
||||
: `A ${typeof value} is not a context config`;
|
||||
notYetHonoured('AgentTeam', 'context', `${unusable}; pass true, a ContextManagerConfig or a ContextManager.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// run_on / tools_run_on placement
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refuse `runOn` on a team, the way Python's `resolve_placement(...,
|
||||
* supports_run_on=False)` does.
|
||||
*
|
||||
* `run_on=` hands ONE agent's whole loop to a managed runtime; a team
|
||||
* orchestrates several agents locally, so there is no single loop to hand over.
|
||||
* Python raises TypeError at construction rather than letting the option sit
|
||||
* there doing nothing, and so does this.
|
||||
*/
|
||||
export function assertTeamPlacement(runOn: unknown, toolsRunOn: unknown, owner: string = 'AgentTeam'): void {
|
||||
if (runOn === undefined || runOn === null) return;
|
||||
const shown = typeof runOn === 'string' ? JSON.stringify(runOn) : String(runOn);
|
||||
if (toolsRunOn !== undefined && toolsRunOn !== null) {
|
||||
const shownTools = typeof toolsRunOn === 'string' ? JSON.stringify(toolsRunOn) : String(toolsRunOn);
|
||||
throw new TypeError(
|
||||
`${owner}(runOn=${shown}, toolsRunOn=${shownTools}) points the tools at two machines. ` +
|
||||
`runOn= already runs everything -- including tools -- on ${shown}.\n` +
|
||||
` Whole agent remote: ${owner}(runOn: ${shown})\n` +
|
||||
` Only tools remote: ${owner}(toolsRunOn: ${shownTools})`
|
||||
);
|
||||
}
|
||||
throw new TypeError(
|
||||
`${owner}(runOn=${shown}) is not supported: runOn= hands one agent's whole ` +
|
||||
`loop to a managed runtime, and a ${owner} orchestrates several agents locally.\n` +
|
||||
` To run every step's tools in one shared sandbox:\n` +
|
||||
` ${owner}(toolsRunOn: ${shown})\n` +
|
||||
` To host a single agent's loop, put runOn on that Agent.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Planning mode for AgentTeam: `AgentTeam({ planning: true })`.
|
||||
*
|
||||
* Python parity: `AgentTeam._run_with_planning`. The team's task descriptions
|
||||
* are joined into one request, a PlanningAgent turns that into a Plan, the plan
|
||||
* is approved (or the run is abandoned), a TodoList tracks it, and the plan's
|
||||
* steps -- not the original tasks -- are what actually run.
|
||||
*/
|
||||
|
||||
import { Plan, PlanningAgent, TodoItem, TodoList } from '../planning';
|
||||
import { notYetHonoured } from '../utils/parity-notice';
|
||||
|
||||
/** Python parity: MultiAgentPlanningConfig. */
|
||||
export interface TeamPlanningSettings {
|
||||
/** Whether planning mode is on at all. */
|
||||
enabled: boolean;
|
||||
/** Model the planner runs on. Python parity: planning_llm (default gpt-4o-mini). */
|
||||
llm: string;
|
||||
/** Python parity: auto_approve_plan. */
|
||||
autoApprove: boolean;
|
||||
/** Custom approval. Python parity: ApprovalCallback(approve_fn=...). */
|
||||
approveFn?: (plan: Plan) => boolean | Promise<boolean>;
|
||||
/** Called when a plan is rejected. Python parity: ApprovalCallback(on_reject=...). */
|
||||
onReject?: (plan: Plan) => void;
|
||||
}
|
||||
|
||||
/** The value `AgentTeam({ planning })` accepts. */
|
||||
export type TeamPlanningInput =
|
||||
| boolean
|
||||
| string
|
||||
| Partial<Omit<TeamPlanningSettings, 'enabled'>>
|
||||
| [string, Partial<Omit<TeamPlanningSettings, 'enabled'>>];
|
||||
|
||||
const DEFAULT_PLANNING_LLM = 'gpt-4o-mini';
|
||||
|
||||
/** Resolve `planning` into planner settings. A string is the planner's model. */
|
||||
export function resolveTeamPlanning(value: unknown): TeamPlanningSettings {
|
||||
const off: TeamPlanningSettings = { enabled: false, llm: DEFAULT_PLANNING_LLM, autoApprove: false };
|
||||
if (value === undefined || value === null || value === false) return off;
|
||||
if (value === true) return { ...off, enabled: true };
|
||||
if (typeof value === 'string') return { ...off, enabled: true, llm: value };
|
||||
|
||||
// Collected as the value is read so the notice has one call site, at the end.
|
||||
let unusable: string | undefined;
|
||||
let settings: TeamPlanningSettings = { ...off, enabled: true };
|
||||
|
||||
const merge = (source: Record<string, unknown>): void => {
|
||||
const llm = source.llm ?? source.model;
|
||||
if (typeof llm === 'string') settings.llm = llm;
|
||||
const autoApprove = source.autoApprove ?? source.auto_approve;
|
||||
if (typeof autoApprove === 'boolean') settings.autoApprove = autoApprove;
|
||||
const approveFn = source.approveFn ?? source.approve_fn;
|
||||
if (typeof approveFn === 'function') settings.approveFn = approveFn as TeamPlanningSettings['approveFn'];
|
||||
const onReject = source.onReject ?? source.on_reject;
|
||||
if (typeof onReject === 'function') settings.onReject = onReject as TeamPlanningSettings['onReject'];
|
||||
// The planner Python builds also takes tools= and reasoning=; the
|
||||
// TypeScript PlanningAgent has neither, so say so rather than drop them.
|
||||
if (source.tools !== undefined || source.reasoning !== undefined) {
|
||||
unusable = 'planning.tools and planning.reasoning are not available on the TypeScript PlanningAgent; the rest of the planning config is applied.';
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const [preset, overrides] = value as [unknown, unknown];
|
||||
if (typeof preset === 'string') settings.llm = preset;
|
||||
if (overrides && typeof overrides === 'object') merge(overrides as Record<string, unknown>);
|
||||
} else if (typeof value === 'object') {
|
||||
merge(value as Record<string, unknown>);
|
||||
} else {
|
||||
settings = off;
|
||||
unusable = `A ${typeof value} is not a planning config; pass true, a planner model name, or an object with llm / autoApprove / approveFn.`;
|
||||
}
|
||||
|
||||
if (unusable) notYetHonoured('AgentTeam', 'planning', unusable);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the planner for a plan covering `request`. Returns null when the planner
|
||||
* produced no steps, which Python treats as "planning failed, fall back to
|
||||
* normal execution".
|
||||
*/
|
||||
export async function createTeamPlan(settings: TeamPlanningSettings, request: string, verbose: boolean): Promise<Plan | null> {
|
||||
const planner = new PlanningAgent({ llm: settings.llm, verbose });
|
||||
const plan = await planner.createPlan(request);
|
||||
return plan.steps.length > 0 ? plan : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the plan may run.
|
||||
*
|
||||
* Python parity: `ApprovalCallback.__call__` -- auto_approve wins, then a
|
||||
* custom approve_fn, then the interactivity rule: a non-interactive process
|
||||
* (CI, a script, a test) approves, an interactive one requires an explicit yes
|
||||
* and therefore rejects a plan nobody approved.
|
||||
*/
|
||||
export async function approveTeamPlan(settings: TeamPlanningSettings, plan: Plan): Promise<boolean> {
|
||||
if (settings.autoApprove) return true;
|
||||
if (settings.approveFn) {
|
||||
const approved = await settings.approveFn(plan);
|
||||
if (!approved && settings.onReject) settings.onReject(plan);
|
||||
return approved;
|
||||
}
|
||||
const interactive = typeof process !== 'undefined' && !!(process.stdin as { isTTY?: boolean } | undefined)?.isTTY;
|
||||
if (!interactive) return true;
|
||||
if (settings.onReject) settings.onReject(plan);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The todo list Python builds from a plan so progress is trackable. */
|
||||
export function todoListFromPlan(plan: Plan): TodoList {
|
||||
const todos = new TodoList(plan.name);
|
||||
for (const step of plan.steps) {
|
||||
todos.add(new TodoItem({ content: step.description, metadata: { stepId: step.id } }));
|
||||
}
|
||||
return todos;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The glue between {@link AgentTeam}'s run loop and the Task execution engine
|
||||
* (`agent/engine/`).
|
||||
*
|
||||
* The engine implements the behaviour behind the Task options that only mean
|
||||
* something to the loop that *runs* tasks — batching, routing, fan-out — but it
|
||||
* deliberately knows nothing about `AgentTeam`. This module is the adapter: it
|
||||
* turns a team's task list into the shapes the engine's list-level functions
|
||||
* expect, and turns their answers back into indices into that list.
|
||||
*
|
||||
* ## Why the imports are narrow
|
||||
*
|
||||
* `agent/engine/index.ts` re-exports every engine module, and two of them
|
||||
* (`task-input-file`, `task-messages`) import `fs` and `path` at module scope.
|
||||
* `agent/simple.ts` re-exports `AgentTeam`, so `team.ts` — and therefore this
|
||||
* file — sits on the graph that `scripts/webview-gate.mjs` bundles for a
|
||||
* browser. Importing the engine barrel here drags `fs`, `path` and the rest of
|
||||
* `src/config` onto that graph and fails the gate (verified with esbuild before
|
||||
* this module was written). Everything below therefore imports the specific
|
||||
* engine modules it needs, all of which are builtin-free.
|
||||
*
|
||||
* The per-task engine behaviour is reached through `Task`'s own methods
|
||||
* (`needsRun`, `buildContext`, `runHandler`, `routeAfter`, ...), which costs
|
||||
* this module no runtime import of `agent/types.ts` at all.
|
||||
*/
|
||||
|
||||
import type { Task } from './types';
|
||||
import { planTaskBatches, type SchedulableTask } from './engine/task-schedule';
|
||||
import { hasWhenRouting, linkPreviousTasks, startTaskOf } from './engine/task-routing';
|
||||
|
||||
/** The part of a team's normalised task entry this module needs to see. */
|
||||
export interface RunnerEntry {
|
||||
/** The originating Task, when the team was given one rather than a prompt string. */
|
||||
task?: Task;
|
||||
}
|
||||
|
||||
/** One step of a team's run plan, as indices into the team's task list. */
|
||||
export interface RunStep {
|
||||
/** True when every index in `indices` may run concurrently (`asyncExecution`). */
|
||||
parallel: boolean;
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** A schedulable stand-in for one entry, so a prompt-string task can be planned too. */
|
||||
interface EntryPlan extends SchedulableTask {
|
||||
index: number;
|
||||
asyncExecution: boolean;
|
||||
dependencies: EntryPlan[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a team's task list into the steps the runner should execute in order.
|
||||
*
|
||||
* Python parity: `Agents.arun_all_tasks` — consecutive `async_execution` tasks
|
||||
* are gathered as one batch, and the batch is flushed before a synchronous task
|
||||
* runs or when a queued task's dependency is still pending
|
||||
* (`Agents._depends_on_pending`).
|
||||
*
|
||||
* The engine's `planTaskBatches` compares dependencies by identity, so the
|
||||
* stand-ins here point at each other rather than at the `Task`s: an entry built
|
||||
* from a prompt string has no Task to compare, and would otherwise never match.
|
||||
*/
|
||||
export function planRunSteps(entries: readonly RunnerEntry[]): RunStep[] {
|
||||
const plans: EntryPlan[] = entries.map((entry, index) => ({
|
||||
index,
|
||||
asyncExecution: entry.task?.asyncExecution ?? false,
|
||||
dependencies: [],
|
||||
}));
|
||||
|
||||
const byTask = new Map<Task, EntryPlan>();
|
||||
entries.forEach((entry, i) => { if (entry.task) byTask.set(entry.task, plans[i]); });
|
||||
entries.forEach((entry, i) => {
|
||||
if (!entry.task) return;
|
||||
for (const dependency of entry.task.dependencies) {
|
||||
const plan = byTask.get(dependency);
|
||||
if (plan) plans[i].dependencies.push(plan);
|
||||
}
|
||||
});
|
||||
|
||||
return planTaskBatches(plans).map((batch) => ({
|
||||
parallel: batch.parallel,
|
||||
indices: batch.tasks.map((plan) => plan.index),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand one Task into the tasks that actually run.
|
||||
*
|
||||
* Python parity: `Process._create_loop_subtasks` (`input_file`) and
|
||||
* `Workflows._run_step_loop` (`loop_over` / `loop_var` / `loop_state`). Both
|
||||
* fan a template task out into one child per row or item; `input_file` wins when
|
||||
* a task somehow declares both, matching the order Python evaluates them in.
|
||||
*
|
||||
* A task that expands to nothing — no `inputFile`, no `loopOver`, an empty file,
|
||||
* or a `loopOver` naming a variable that is missing or not a list — comes back
|
||||
* as itself, which is Python's "run the step once" fallback.
|
||||
*/
|
||||
export function expandTask(task: Task, variables: Record<string, unknown>): Task[] {
|
||||
if (task.inputFile) {
|
||||
try {
|
||||
const rows = task.expandFromInputFile();
|
||||
if (rows.length > 0) return rows;
|
||||
} catch (err) {
|
||||
// Python wraps the whole of _create_loop_subtasks in a bare `except` that
|
||||
// only logs, and the workflow proceeds as if the loop had no rows.
|
||||
task.nonFatalErrors.push(`input_file: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
if (task.loopOver) {
|
||||
const items = task.expandLoop(variables);
|
||||
if (items.length > 0) return items;
|
||||
}
|
||||
return [task];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this task list actually declares a workflow graph.
|
||||
*
|
||||
* A team whose tasks name no edges, no start task and no conditions is a plain
|
||||
* list, and Python's `process="workflow"` then walks it in order — which is what
|
||||
* the TypeScript team did before routing existed. Consulting the router for such
|
||||
* a list would stop after the first task, so the runner asks this first.
|
||||
*/
|
||||
export function declaresRouting(entries: readonly RunnerEntry[]): boolean {
|
||||
return entries.some((entry) => {
|
||||
const task = entry.task;
|
||||
if (!task) return false;
|
||||
return task.isStart
|
||||
|| task.nextTasks.length > 0
|
||||
|| Object.keys(task.condition).length > 0
|
||||
|| hasWhenRouting(task);
|
||||
});
|
||||
}
|
||||
|
||||
/** Where a workflow run starts: the index of `startTaskOf`'s pick, else 0. */
|
||||
export function startIndexOf(entries: readonly RunnerEntry[]): number {
|
||||
const tasks = entries.map((e) => e.task).filter((t): t is Task => t !== undefined);
|
||||
if (tasks.length === 0) return 0;
|
||||
const start = startTaskOf(tasks);
|
||||
if (!start) return 0;
|
||||
const index = entries.findIndex((e) => e.task === start);
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every task's `previousTasks` from the `nextTasks` edges of the others,
|
||||
* as Python does at the top of `process.workflow()`. Entries without a Task are
|
||||
* ignored.
|
||||
*/
|
||||
export function linkEntries(entries: readonly RunnerEntry[]): void {
|
||||
linkPreviousTasks(entries.map((e) => e.task).filter((t): t is Task => t !== undefined));
|
||||
}
|
||||
|
||||
/** Index the entries by every name routing can address them with. */
|
||||
export function indexByName(entries: ReadonlyArray<RunnerEntry & { name: string }>): Map<string, number> {
|
||||
const byName = new Map<string, number>();
|
||||
entries.forEach((entry, index) => {
|
||||
// The Task's own name is what `nextTasks` / `condition` targets refer to;
|
||||
// the entry name (which falls back to `task_<n>`) is registered too so a
|
||||
// routing target can name an unnamed task the way results are keyed.
|
||||
if (!byName.has(entry.name)) byName.set(entry.name, index);
|
||||
const taskName = entry.task?.name;
|
||||
if (taskName && !byName.has(taskName)) byName.set(taskName, index);
|
||||
});
|
||||
return byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `decision` a `decision`/`loop` task's answer carries.
|
||||
*
|
||||
* Python reads `result.pydantic.decision` and falls back to the lowercased raw
|
||||
* text (`process.py`); here a JSON answer with a `decision` field is read the
|
||||
* same way, and anything else is the raw text.
|
||||
*/
|
||||
export function decisionFrom(raw: string): string {
|
||||
const text = raw.trim();
|
||||
if (text.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
const decision = parsed.decision;
|
||||
if (typeof decision === 'string') return decision.toLowerCase().trim();
|
||||
} catch {
|
||||
// Not JSON: the raw text is the decision.
|
||||
}
|
||||
}
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
/** True when one of the task's declared dependencies ended the run failed. */
|
||||
export function dependenciesFailed(task: Task): boolean {
|
||||
return task.dependencies.some((dependency) => dependency.status === 'failed');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -476,7 +476,11 @@ export class Task {
|
||||
for (const option of ENGINE_LEVEL_OPTIONS) {
|
||||
if (config[option] !== undefined) notYetHonoured('Task', option);
|
||||
}
|
||||
if (config.taskType !== undefined && config.taskType !== 'task') notYetHonoured('Task', 'taskType');
|
||||
// 'decision' is honoured: the runner reads it to drive the routing table
|
||||
// (see team-runner.ts). Anything else still has no meaning here.
|
||||
if (config.taskType !== undefined && config.taskType !== 'task' && config.taskType !== 'decision') {
|
||||
notYetHonoured('Task', 'taskType');
|
||||
}
|
||||
if (typeof this.guardrail === 'string') {
|
||||
notYetHonoured('Task', 'guardrails', 'String guardrails need an LLM judge; callable guardrails run via runGuardrail().');
|
||||
}
|
||||
|
||||
@@ -34,15 +34,10 @@
|
||||
*/
|
||||
export const UNHONOURED_OPTIONS: Readonly<Record<string, readonly string[]>> = {
|
||||
'AgentTeam.__init__': [
|
||||
'managerLlm', 'memory', 'planning', 'context', 'execution', 'hooks', 'autonomy', 'knowledge',
|
||||
'guardrails', 'web', 'reflection', 'caching', 'learn', 'toolsRunOn', 'runOn',
|
||||
'autonomy', 'knowledge', 'guardrails', 'web', 'reflection', 'caching', 'learn', 'toolsRunOn',
|
||||
],
|
||||
'Task.__init__': [
|
||||
'asyncExecution', 'config', 'outputPydantic', 'images', 'nextTasks', 'condition', 'isStart',
|
||||
'loopState', 'memory', 'inputFile', 'rerun', 'retainFullContext', 'agentConfig', 'skipOnFailure',
|
||||
'retryDelay', 'handler', 'loopOver', 'loopVar', 'execution', 'routing', 'outputConfig', 'when',
|
||||
'thenTask', 'elseTask', 'autonomy', 'knowledge', 'web', 'reflection', 'planning', 'hooks',
|
||||
'caching', 'failOnMemoryError',
|
||||
'autonomy', 'web', 'reflection', 'planning',
|
||||
],
|
||||
'Handoff': [
|
||||
'contextPolicy', 'maxContextTokens', 'maxContextMessages', 'preserveSystem',
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Behaviour parity for the AgentTeam options that used to be accepted and then
|
||||
* ignored: memory, context, hooks, execution, planning, runOn and managerLlm.
|
||||
*
|
||||
* Every test here pairs "the option is set" with a control showing the same
|
||||
* team does something different without it. The model is stubbed; nothing
|
||||
* touches the network.
|
||||
*/
|
||||
process.env.PRAISONAI_PARITY_SILENT = '1';
|
||||
|
||||
import { Agent, AgentTeam } from '../../../src/agent/simple';
|
||||
import { resetParityNotices } from '../../../src/utils/parity-notice';
|
||||
|
||||
/**
|
||||
* One stub for every OpenAIService the run creates, tagged with its model so a
|
||||
* call can be traced back to the agent that made it. `responder` lets a test
|
||||
* answer as the manager or the planner; anything it does not answer echoes the
|
||||
* prompt.
|
||||
*/
|
||||
const mockLlm = {
|
||||
calls: [] as Array<{ model: string; prompt: string }>,
|
||||
responder: null as null | ((model: string, prompt: string) => string | undefined),
|
||||
};
|
||||
|
||||
const answer = (model: string, prompt: string): string => {
|
||||
mockLlm.calls.push({ model, prompt });
|
||||
return mockLlm.responder?.(model, prompt) ?? `reply(${prompt})`;
|
||||
};
|
||||
|
||||
const lastMessage = (messages: any[]): string => messages[messages.length - 1]?.content ?? '';
|
||||
|
||||
jest.mock('../../../src/llm/openai', () => ({
|
||||
OpenAIService: jest.fn().mockImplementation((model: string) => ({
|
||||
model,
|
||||
generateText: jest.fn(async (prompt: string) => answer(model, prompt)),
|
||||
generateChat: jest.fn(async (messages: any[]) => ({ content: answer(model, lastMessage(messages)), role: 'assistant' })),
|
||||
streamChat: jest.fn(async (messages: any[]) => answer(model, lastMessage(messages))),
|
||||
streamChatWithTools: jest.fn(async (messages: any[]) => ({ content: answer(model, lastMessage(messages)), role: 'assistant' })),
|
||||
})),
|
||||
}));
|
||||
|
||||
const quiet = { verbose: false, stream: false } as const;
|
||||
const promptsFor = (model: string): string[] => mockLlm.calls.filter((c) => c.model === model).map((c) => c.prompt);
|
||||
|
||||
beforeEach(() => {
|
||||
resetParityNotices();
|
||||
mockLlm.calls = [];
|
||||
mockLlm.responder = null;
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// memory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam memory', () => {
|
||||
const seededStore = () => ({
|
||||
search: jest.fn(async (_query: string, _limit?: number) => [{ entry: { content: 'whales migrate in winter', role: 'assistant' }, score: 1 }]),
|
||||
add: jest.fn(async (_content: string, _role: 'user' | 'assistant' | 'system', _metadata?: Record<string, any>) => undefined),
|
||||
});
|
||||
|
||||
const team = (memory?: unknown) => new AgentTeam({
|
||||
agents: [new Agent({ name: 'solo', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['summarise whales'],
|
||||
memory: memory as any,
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('appends what it recalls to the task prompt and writes the result back', async () => {
|
||||
const store = seededStore();
|
||||
const results = await team(store).start();
|
||||
|
||||
expect(promptsFor('member-model')[0]).toContain('• whales migrate in winter');
|
||||
expect(store.search).toHaveBeenCalledTimes(1);
|
||||
// The finished task is remembered: the prompt as the user turn, the answer
|
||||
// as the assistant turn (Python parity: the task's memory callback).
|
||||
expect(store.add).toHaveBeenCalledTimes(2);
|
||||
expect(store.add.mock.calls[1]?.[0]).toBe(results[0]);
|
||||
});
|
||||
|
||||
it('control: without memory nothing is recalled and nothing is stored', async () => {
|
||||
const store = seededStore();
|
||||
await team(undefined).start();
|
||||
expect(promptsFor('member-model')[0]).not.toContain('whales migrate in winter');
|
||||
expect(store.search).not.toHaveBeenCalled();
|
||||
expect(store.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memory: true opens a shared store; the default leaves the team without one', () => {
|
||||
expect(team(true).getSharedMemory()).toBeDefined();
|
||||
expect(team(undefined).getSharedMemory()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// context
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam context', () => {
|
||||
const team = (context?: unknown) => new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one', 'task two', 'task three'],
|
||||
context: context as any,
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('hands the third task everything the team produced, oldest first', async () => {
|
||||
await team(true).start();
|
||||
const third = promptsFor('member-model')[2];
|
||||
// The shared context is replayed in order: task one's answer, then task two's.
|
||||
expect(third).toContain('Here is the input: reply(task one)\n\nreply(task two');
|
||||
});
|
||||
|
||||
it('control: without context the third task is handed only the previous result', async () => {
|
||||
await team(undefined).start();
|
||||
const third = promptsFor('member-model')[2];
|
||||
// Only the immediately preceding answer, with task one nested inside it.
|
||||
expect(third).toContain('Here is the input: reply(task two');
|
||||
expect(third).not.toContain('reply(task one)\n\nreply(task two');
|
||||
});
|
||||
|
||||
it('exposes the shared ContextManager only when context is on', () => {
|
||||
expect(team(true).getContextManager()).toBeDefined();
|
||||
expect(team(undefined).getContextManager()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// hooks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam hooks', () => {
|
||||
const makeTeam = (hooks?: unknown) => new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one', 'task two'],
|
||||
hooks: hooks as any,
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('fires onTaskStart and onTaskComplete around every task', async () => {
|
||||
const onTaskStart = jest.fn();
|
||||
const onTaskComplete = jest.fn();
|
||||
await makeTeam({ onTaskStart, onTaskComplete }).start();
|
||||
|
||||
expect(onTaskStart).toHaveBeenCalledTimes(2);
|
||||
expect(onTaskStart.mock.calls[0][0]).toMatchObject({ name: 'task_1', description: 'task one', status: 'in progress' });
|
||||
expect(onTaskStart.mock.calls[0][1]).toBe(0);
|
||||
expect(onTaskComplete).toHaveBeenCalledTimes(2);
|
||||
expect(onTaskComplete.mock.calls[1][0]).toMatchObject({ name: 'task_2', status: 'completed' });
|
||||
expect(onTaskComplete.mock.calls[1][1].agent).toBe('one');
|
||||
expect(onTaskComplete.mock.calls[1][1].raw).toContain('reply(task two');
|
||||
});
|
||||
|
||||
it('control: without hooks nothing is called and each task runs once', async () => {
|
||||
const team = makeTeam(undefined);
|
||||
expect(team.onTaskStart).toBeUndefined();
|
||||
expect(team.onTaskComplete).toBeUndefined();
|
||||
await team.start();
|
||||
expect(promptsFor('member-model')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a completionChecker that refuses the answer drives the retry loop and fails the task', async () => {
|
||||
const completionChecker = jest.fn(() => false);
|
||||
const onTaskComplete = jest.fn();
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one'],
|
||||
hooks: { completionChecker, onTaskComplete },
|
||||
execution: { maxRetries: 3 },
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
|
||||
expect(promptsFor('member-model')).toHaveLength(3);
|
||||
expect(completionChecker).toHaveBeenCalledTimes(3);
|
||||
expect(onTaskComplete.mock.calls[0][0]).toMatchObject({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('control: the default checker accepts a non-empty answer, so the task runs once', async () => {
|
||||
await new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one'],
|
||||
execution: { maxRetries: 3 },
|
||||
...quiet,
|
||||
}).start();
|
||||
expect(promptsFor('member-model')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// execution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam execution', () => {
|
||||
const attemptsWith = async (execution?: unknown): Promise<number> => {
|
||||
mockLlm.calls = [];
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one'],
|
||||
hooks: { completionChecker: () => false },
|
||||
execution: execution as any,
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
return promptsFor('member-model').length;
|
||||
};
|
||||
|
||||
it('sets the retry ceiling from a preset, an object, or the Python default', async () => {
|
||||
// "fast" is max_retries 2 in Python, lifted to the floor of 3.
|
||||
expect(await attemptsWith('fast')).toBe(3);
|
||||
expect(await attemptsWith({ maxRetries: 7 })).toBe(7);
|
||||
expect(await attemptsWith(undefined)).toBe(5); // control: the Python default
|
||||
});
|
||||
|
||||
it('exposes the resolved limits, preset table included', () => {
|
||||
const agents = [new Agent({ name: 'one', instructions: 'a', ...quiet })];
|
||||
expect(new AgentTeam({ agents, execution: 'thorough', ...quiet }).maxIter).toBe(20);
|
||||
expect(new AgentTeam({ agents, ...quiet }).maxIter).toBe(10);
|
||||
});
|
||||
|
||||
it('maxIter bounds the hierarchical manager loop', async () => {
|
||||
const runWith = async (maxIter: number): Promise<number> => {
|
||||
mockLlm.calls = [];
|
||||
mockLlm.responder = (model) =>
|
||||
model === 'manager-model' ? '{"task_id": 0, "agent_name": "one", "action": "execute"}' : undefined;
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one'],
|
||||
process: 'hierarchical',
|
||||
managerLlm: 'manager-model',
|
||||
// The task can never complete, so only maxIter ends the loop.
|
||||
hooks: { completionChecker: () => false },
|
||||
execution: { maxIter, maxRetries: 3 },
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
return promptsFor('manager-model').length;
|
||||
};
|
||||
|
||||
expect(await runWith(2)).toBe(2);
|
||||
expect(await runWith(4)).toBe(4); // control: a different ceiling, a different count
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// planning
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam planning', () => {
|
||||
const planReply = '1. Gather sources\n2. Draft the answer';
|
||||
const planningResponder = (_model: string, prompt: string) =>
|
||||
prompt.includes('Create a step-by-step plan for') ? planReply : undefined;
|
||||
|
||||
const makeTeam = (planning?: unknown) => new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['research whales'],
|
||||
planning: planning as any,
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('plans first and runs the plan steps instead of the original tasks', async () => {
|
||||
mockLlm.responder = planningResponder;
|
||||
const team = makeTeam(true);
|
||||
const results = await team.start();
|
||||
|
||||
// The planner ran on the Python default planning model.
|
||||
expect(promptsFor('gpt-4o-mini')[0]).toContain('Create a step-by-step plan for: research whales');
|
||||
// The steps, not the task, are what the member was asked to do.
|
||||
const member = promptsFor('member-model');
|
||||
expect(member).toHaveLength(2);
|
||||
expect(member[0]).toContain('Gather sources');
|
||||
expect(member[1]).toContain('Draft the answer');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(team.getPlan()?.steps.map((s) => s.description)).toEqual(['Gather sources', 'Draft the answer']);
|
||||
expect(team.getTodoList()?.getCompleted()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('control: without planning the original task runs and no planner is asked', async () => {
|
||||
mockLlm.responder = planningResponder;
|
||||
const team = makeTeam(undefined);
|
||||
await team.start();
|
||||
expect(promptsFor('gpt-4o-mini')).toHaveLength(0);
|
||||
expect(promptsFor('member-model')[0]).toContain('research whales');
|
||||
expect(team.getPlan()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a string names the planner model; a rejected plan abandons the run', async () => {
|
||||
mockLlm.responder = planningResponder;
|
||||
await makeTeam('planner-model').start();
|
||||
expect(promptsFor('planner-model')[0]).toContain('Create a step-by-step plan for');
|
||||
|
||||
mockLlm.calls = [];
|
||||
const rejected = makeTeam({ approveFn: () => false });
|
||||
expect(await rejected.start()).toEqual([]);
|
||||
expect(promptsFor('member-model')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// runOn
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam runOn', () => {
|
||||
const agents = () => [new Agent({ name: 'one', instructions: 'a', ...quiet })];
|
||||
|
||||
it('is refused, because a team has no single loop to hand to a runtime', () => {
|
||||
expect(() => new AgentTeam({ agents: agents(), runOn: 'openai', ...quiet }))
|
||||
.toThrow(/runOn="openai".*is not supported/s);
|
||||
expect(() => new AgentTeam({ agents: agents(), runOn: 'openai', toolsRunOn: 'docker', ...quiet }))
|
||||
.toThrow(/points the tools at two machines/);
|
||||
});
|
||||
|
||||
it('control: the same team without runOn constructs, and team.runOn stays undefined', () => {
|
||||
const team = new AgentTeam({ agents: agents(), toolsRunOn: 'docker', ...quiet });
|
||||
expect(team.runOn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// managerLlm (hierarchical)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam managerLlm', () => {
|
||||
const makeTeam = (process: 'hierarchical' | 'sequential') => new AgentTeam({
|
||||
agents: [
|
||||
new Agent({ name: 'alpha', instructions: 'a', llm: 'alpha-model', ...quiet }),
|
||||
new Agent({ name: 'beta', instructions: 'b', llm: 'beta-model', ...quiet }),
|
||||
],
|
||||
tasks: ['task one', 'task two'],
|
||||
process,
|
||||
managerLlm: 'manager-model',
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('a manager on managerLlm picks the order and the member', async () => {
|
||||
let asked = 0;
|
||||
mockLlm.responder = (model) => {
|
||||
if (model !== 'manager-model') return undefined;
|
||||
asked += 1;
|
||||
return asked === 1
|
||||
? '```json\n{"task_id": 1, "agent_name": "alpha", "action": "execute"}\n```'
|
||||
: '{"task_id": 0, "agent_name": "beta", "action": "execute"}';
|
||||
};
|
||||
|
||||
const results = await makeTeam('hierarchical').start();
|
||||
|
||||
// The manager was asked on its own model, and was shown the task table.
|
||||
expect(promptsFor('manager-model')[0]).toContain('"task_id": 0');
|
||||
// Its picks won: task two ran first, on alpha; task one second, on beta.
|
||||
expect(promptsFor('alpha-model')[0]).toContain('task two');
|
||||
expect(promptsFor('beta-model')[0]).toContain('task one');
|
||||
expect(mockLlm.calls.map((c) => c.model)).toEqual([
|
||||
'manager-model', 'alpha-model', 'manager-model', 'beta-model',
|
||||
]);
|
||||
// Results stay in task order whatever order the manager chose.
|
||||
expect(results[0]).toContain('task one');
|
||||
expect(results[1]).toContain('task two');
|
||||
});
|
||||
|
||||
it('control: the same team run sequentially never asks a manager', async () => {
|
||||
await makeTeam('sequential').start();
|
||||
expect(promptsFor('manager-model')).toHaveLength(0);
|
||||
expect(mockLlm.calls.map((c) => c.model)).toEqual(['alpha-model', 'beta-model']);
|
||||
});
|
||||
|
||||
it('a manager that says stop ends the run with nothing delegated', async () => {
|
||||
mockLlm.responder = (model) =>
|
||||
model === 'manager-model' ? '{"task_id": 0, "agent_name": "alpha", "action": "stop"}' : undefined;
|
||||
const results = await makeTeam('hierarchical').start();
|
||||
expect(results).toEqual(['', '']);
|
||||
expect(promptsFor('alpha-model')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// repeated runs (Python parity: per-run state reset)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam repeated runs', () => {
|
||||
it('a reused sequential team runs its tasks again, not stale completed state', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['task one', 'task two'],
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
expect(promptsFor('member-model')).toHaveLength(2);
|
||||
|
||||
mockLlm.calls = [];
|
||||
await team.start();
|
||||
// The second run executes both tasks afresh rather than skipping them.
|
||||
expect(promptsFor('member-model')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('a reused hierarchical team delegates again instead of returning last run', async () => {
|
||||
// One task: the manager executes it on alpha; once completed the loop exits,
|
||||
// so a single execute instruction is enough for each run.
|
||||
mockLlm.responder = (model) =>
|
||||
model === 'manager-model' ? '{"task_id": 0, "agent_name": "alpha", "action": "execute"}' : undefined;
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'alpha', instructions: 'a', llm: 'alpha-model', ...quiet })],
|
||||
tasks: ['only task'],
|
||||
process: 'hierarchical',
|
||||
managerLlm: 'manager-model',
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
expect(promptsFor('alpha-model')).toHaveLength(1);
|
||||
|
||||
mockLlm.calls = [];
|
||||
await team.start();
|
||||
// Without the reset the manager loop would count the task completed and exit
|
||||
// immediately; the reset makes the second run delegate it once more.
|
||||
expect(promptsFor('alpha-model')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// planning: one-run replacement, partial completion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam planning lifecycle', () => {
|
||||
const planningResponder = (_model: string, prompt: string) =>
|
||||
prompt.includes('Create a step-by-step plan for') ? '1. Gather sources\n2. Draft the answer' : undefined;
|
||||
|
||||
it('re-plans from the original tasks on the next run, not from the last plan steps', async () => {
|
||||
mockLlm.responder = planningResponder;
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['research whales'],
|
||||
planning: true,
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
mockLlm.calls = [];
|
||||
await team.start();
|
||||
|
||||
// The second planning request is still the configured task, proving the
|
||||
// plan steps did not permanently replace it.
|
||||
expect(promptsFor('gpt-4o-mini')[0]).toContain('Create a step-by-step plan for: research whales');
|
||||
});
|
||||
|
||||
it('marks only the plan steps whose task completed, not every todo', async () => {
|
||||
// The planner produces two steps; the completion checker refuses the second.
|
||||
mockLlm.responder = planningResponder;
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'one', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['research whales'],
|
||||
planning: true,
|
||||
execution: { maxRetries: 1 },
|
||||
hooks: { completionChecker: (_task, output) => !output.includes('Draft the answer') },
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
|
||||
// Step one completed; step two was left failed, so the todo is not complete.
|
||||
expect(team.getTodoList()?.getCompleted()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// memory scope (security: recall must not cross userId)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AgentTeam memory scope', () => {
|
||||
it('recall keeps only this team\'s own entries when a store is shared across users', async () => {
|
||||
const store = {
|
||||
search: jest.fn(async () => [
|
||||
{ entry: { content: 'other user secret', role: 'assistant', metadata: { userId: 'someone-else' } }, score: 1 },
|
||||
{ entry: { content: 'my own note', role: 'assistant', metadata: { userId: 'me' } }, score: 0.9 },
|
||||
{ entry: { content: 'untagged shared note', role: 'assistant' }, score: 0.8 },
|
||||
]),
|
||||
add: jest.fn(async () => undefined),
|
||||
};
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ name: 'solo', instructions: 'a', llm: 'member-model', ...quiet })],
|
||||
tasks: ['summarise'],
|
||||
memory: { userId: 'me', config: store } as any,
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
|
||||
const prompt = promptsFor('member-model')[0];
|
||||
expect(prompt).toContain('• my own note');
|
||||
expect(prompt).toContain('• untagged shared note');
|
||||
expect(prompt).not.toContain('other user secret');
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,6 @@ describe('AgentTeam.__init__ parity', () => {
|
||||
caching: true,
|
||||
learn: true,
|
||||
toolsRunOn: 'docker',
|
||||
runOn: 'anthropic',
|
||||
});
|
||||
expect(team.name).toBe('research-team');
|
||||
expect(team.variables).toEqual({ topic: 'whales' });
|
||||
@@ -75,10 +74,12 @@ describe('AgentTeam.__init__ parity', () => {
|
||||
expect(team.planning).toBe(true);
|
||||
expect(team.context).toBe(true);
|
||||
expect(team.output).toBe('normal');
|
||||
// memory, planning, context, execution, hooks, managerLlm and runOn are
|
||||
// acted on now (see parity-agentteam-features.test.ts); the rest are still
|
||||
// accepted-with-notice.
|
||||
expect(unhonouredOptions()).toEqual([
|
||||
'AgentTeam.autonomy', 'AgentTeam.caching', 'AgentTeam.context', 'AgentTeam.execution', 'AgentTeam.guardrails',
|
||||
'AgentTeam.hooks', 'AgentTeam.knowledge', 'AgentTeam.learn', 'AgentTeam.managerLlm', 'AgentTeam.memory',
|
||||
'AgentTeam.planning', 'AgentTeam.reflection', 'AgentTeam.runOn', 'AgentTeam.toolsRunOn', 'AgentTeam.web',
|
||||
'AgentTeam.autonomy', 'AgentTeam.caching', 'AgentTeam.guardrails', 'AgentTeam.knowledge',
|
||||
'AgentTeam.learn', 'AgentTeam.reflection', 'AgentTeam.toolsRunOn', 'AgentTeam.web',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -191,11 +192,14 @@ describe('AgentTeam.start parity', () => {
|
||||
expect(Object.keys(await named.start(undefined, { returnDict: true }))).toEqual(['summarise']);
|
||||
});
|
||||
|
||||
it('workflow and hierarchical run sequentially; hierarchical reports the missing manager', async () => {
|
||||
it('workflow runs sequentially', async () => {
|
||||
await makeTeam('workflow').start();
|
||||
expect(promptOf(mockLlm.calls[1])).toContain('Here is the input: reply(task one');
|
||||
});
|
||||
|
||||
mockLlm.calls = [];
|
||||
it('hierarchical asks the manager first and delegates nothing when the reply is not the requested JSON', async () => {
|
||||
// Python parity: Process.hierarchical breaks on a manager parse error, so
|
||||
// no member task runs. The mocked model answers prose, not JSON.
|
||||
const team = new AgentTeam({
|
||||
agents: [new Agent({ instructions: 'a', ...quiet }), new Agent({ instructions: 'b', ...quiet })],
|
||||
tasks: ['task one', 'task two'],
|
||||
@@ -203,10 +207,11 @@ describe('AgentTeam.start parity', () => {
|
||||
managerLlm: 'gpt-4o',
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
expect(mockLlm.calls).toHaveLength(2);
|
||||
expect(promptOf(mockLlm.calls[1])).toContain('Here is the input: reply(task one');
|
||||
expect(unhonouredOptions()).toEqual(['AgentTeam.managerLlm']);
|
||||
const results = await team.start();
|
||||
expect(mockLlm.calls.map((c) => c.model)).toEqual(['gpt-4o']);
|
||||
expect(promptOf(mockLlm.calls[0])).toContain('Provide a JSON with the structure');
|
||||
expect(results).toEqual(['', '']);
|
||||
expect(unhonouredOptions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('output preset per run: an unknown preset is reported, "silent" is honoured', async () => {
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* Behaviour parity for the `Task` options whose behaviour lives in the run
|
||||
* loop rather than in `Task` itself.
|
||||
*
|
||||
* The execution engine under `src/agent/engine/` already had unit tests: they
|
||||
* prove `planTaskBatches` batches, `resolveNextTask` routes and `runTaskHandler`
|
||||
* runs a handler. What they could not prove is that anything CALLS them --
|
||||
* and for a while nothing did, so a `Task` could be handed `handler`,
|
||||
* `loopOver` or `rerun` and the team would run exactly as if it had not been.
|
||||
*
|
||||
* Every test here therefore drives the option through the public API: build an
|
||||
* `AgentTeam` with real `Task` objects, call `team.start()`, and assert the
|
||||
* observable difference -- which prompts reached the model, which tasks ran,
|
||||
* what the callbacks saw. Each is paired with a control showing the same team
|
||||
* behaves differently when the option is absent. The model is stubbed;
|
||||
* nothing touches the network or an API key.
|
||||
*/
|
||||
process.env.PRAISONAI_PARITY_SILENT = '1';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { Agent, AgentTeam } from '../../../src/agent/simple';
|
||||
import { Task } from '../../../src/agent/types';
|
||||
import { resetParityNotices } from '../../../src/utils/parity-notice';
|
||||
|
||||
/**
|
||||
* One stub for every OpenAIService the run creates. `content` is kept exactly
|
||||
* as the Agent sent it, because a multimodal call sends an array rather than a
|
||||
* string and that difference is the whole `images` test.
|
||||
*
|
||||
* `delayMs` plus the in-flight counters are how `asyncExecution` is proved:
|
||||
* two calls overlapping in time is a fact no prompt assertion can show.
|
||||
*/
|
||||
const mockLlm = {
|
||||
calls: [] as Array<{ model: string; content: unknown }>,
|
||||
responder: null as null | ((model: string, prompt: string) => string | undefined),
|
||||
delayMs: 0,
|
||||
inFlight: 0,
|
||||
maxInFlight: 0,
|
||||
};
|
||||
|
||||
/** The text of a message whether it was sent as a string or as content parts. */
|
||||
const textOf = (content: unknown): string => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((part: any) => part?.type === 'text')
|
||||
.map((part: any) => part.text)
|
||||
.join('\n');
|
||||
}
|
||||
return String(content ?? '');
|
||||
};
|
||||
|
||||
const answer = async (model: string, content: unknown): Promise<string> => {
|
||||
mockLlm.calls.push({ model, content });
|
||||
mockLlm.inFlight += 1;
|
||||
mockLlm.maxInFlight = Math.max(mockLlm.maxInFlight, mockLlm.inFlight);
|
||||
if (mockLlm.delayMs > 0) await new Promise((resolve) => setTimeout(resolve, mockLlm.delayMs));
|
||||
mockLlm.inFlight -= 1;
|
||||
return mockLlm.responder?.(model, textOf(content)) ?? `reply(${textOf(content)})`;
|
||||
};
|
||||
|
||||
const lastContent = (messages: any[]): unknown => messages[messages.length - 1]?.content;
|
||||
|
||||
jest.mock('../../../src/llm/openai', () => ({
|
||||
OpenAIService: jest.fn().mockImplementation((model: string) => ({
|
||||
model,
|
||||
generateText: jest.fn(async (prompt: string) => answer(model, prompt)),
|
||||
generateChat: jest.fn(async (messages: any[]) => ({ content: await answer(model, lastContent(messages)), role: 'assistant' })),
|
||||
streamChat: jest.fn(async (messages: any[]) => answer(model, lastContent(messages))),
|
||||
streamChatWithTools: jest.fn(async (messages: any[]) => ({ content: await answer(model, lastContent(messages)), role: 'assistant' })),
|
||||
})),
|
||||
}));
|
||||
|
||||
const quiet = { verbose: false, stream: false } as const;
|
||||
const member = (name = 'member', llm = 'member-model') => new Agent({ name, instructions: 'help', llm, ...quiet });
|
||||
/** Every prompt the model was asked, as text. */
|
||||
const prompts = (): string[] => mockLlm.calls.map((call) => textOf(call.content));
|
||||
|
||||
beforeEach(() => {
|
||||
resetParityNotices();
|
||||
mockLlm.calls = [];
|
||||
mockLlm.responder = null;
|
||||
mockLlm.delayMs = 0;
|
||||
mockLlm.inFlight = 0;
|
||||
mockLlm.maxInFlight = 0;
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// handler
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task handler', () => {
|
||||
it('runs the task s own function instead of the model', async () => {
|
||||
const handler = jest.fn(() => 'computed without an LLM');
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'compute', description: 'add the numbers', handler })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(results).toEqual(['computed without an LLM']);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
// The handler was given the workflow context, not the bare prompt.
|
||||
expect((handler.mock.calls[0] as any[])[0]).toMatchObject({ currentStep: 'compute' });
|
||||
// The point of a handler: the model is never asked.
|
||||
expect(mockLlm.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('control: without a handler the same task calls the model', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'compute', description: 'add the numbers' })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(mockLlm.calls).toHaveLength(1);
|
||||
expect(results[0]).toContain('reply(add the numbers');
|
||||
});
|
||||
|
||||
it('publishes the handler s variables into the run, so a later prompt sees them', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [
|
||||
new Task({ name: 'gather', description: 'gather', handler: () => ({ output: 'raw data', variables: { topic: 'whales' } }) }),
|
||||
new Task({ name: 'write', description: 'write about {{topic}}' }),
|
||||
],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(prompts()[0]).toContain('write about whales');
|
||||
});
|
||||
|
||||
it('control: a handler returning a plain string publishes nothing', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [
|
||||
new Task({ name: 'gather', description: 'gather', handler: () => 'raw data' }),
|
||||
new Task({ name: 'write', description: 'write about {{topic}}' }),
|
||||
],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(prompts()[0]).toContain('write about {{topic}}');
|
||||
});
|
||||
|
||||
it('stopWorkflow ends the run, so the following task never starts', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [
|
||||
new Task({ name: 'halt', description: 'halt', handler: () => ({ output: 'stopping', stopWorkflow: true }) }),
|
||||
new Task({ name: 'after', description: 'after' }),
|
||||
],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(mockLlm.calls).toHaveLength(0);
|
||||
expect(results).toEqual(['stopping', '']);
|
||||
});
|
||||
|
||||
it('a throwing handler fails its task without failing the run', async () => {
|
||||
const failing = new Task({ name: 'boom', description: 'boom', handler: () => { throw new Error('handler exploded'); } });
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [failing, new Task({ name: 'after', description: 'after' })], ...quiet });
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(failing.status).toBe('failed');
|
||||
expect(results[0]).toBe('');
|
||||
// The unrelated task still ran.
|
||||
expect(prompts()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// rerun / caching / execution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task rerun', () => {
|
||||
const teamFor = (task: Task) => new AgentTeam({ agents: [member()], tasks: [task, task], ...quiet });
|
||||
|
||||
it('re-runs a task the team reaches a second time', async () => {
|
||||
await teamFor(new Task({ name: 'poll', description: 'poll the queue', rerun: true })).start();
|
||||
expect(mockLlm.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('control: without rerun the second visit is skipped', async () => {
|
||||
await teamFor(new Task({ name: 'poll', description: 'poll the queue' })).start();
|
||||
expect(mockLlm.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('execution: { rerun } reaches the same switch', async () => {
|
||||
await teamFor(new Task({ name: 'poll', description: 'poll the queue', execution: { rerun: true } })).start();
|
||||
expect(mockLlm.calls).toHaveLength(2);
|
||||
|
||||
mockLlm.calls = [];
|
||||
await teamFor(new Task({ name: 'poll', description: 'poll the queue', execution: { rerun: false } })).start();
|
||||
expect(mockLlm.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task caching', () => {
|
||||
const teamFor = (task: Task) => new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
it('answers a repeated prompt from the cache instead of the model', async () => {
|
||||
const task = new Task({ name: 'quote', description: 'quote the price', rerun: true, caching: true });
|
||||
const team = teamFor(task);
|
||||
|
||||
const first = await team.start();
|
||||
const second = await team.start();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(mockLlm.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('control: without caching the second run asks the model again', async () => {
|
||||
const team = teamFor(new Task({ name: 'quote', description: 'quote the price', rerun: true }));
|
||||
|
||||
await team.start();
|
||||
await team.start();
|
||||
|
||||
expect(mockLlm.calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// agentConfig
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task agentConfig', () => {
|
||||
it('builds the executing agent from the config when the task names none', async () => {
|
||||
const task = new Task({
|
||||
name: 'research',
|
||||
description: 'research whales',
|
||||
agentConfig: { llm: 'config-model', instructions: 'be brief', stream: false, verbose: false },
|
||||
});
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(mockLlm.calls.map((call) => call.model)).toEqual(['config-model']);
|
||||
// Python assigns the constructed agent back onto the task; so does this.
|
||||
expect((task.agent as Agent).name).toBe('researchAgent');
|
||||
});
|
||||
|
||||
it('control: without agentConfig the task runs on the team member', async () => {
|
||||
const task = new Task({ name: 'research', description: 'research whales' });
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(mockLlm.calls.map((call) => call.model)).toEqual(['member-model']);
|
||||
expect(task.agent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// retainFullContext
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task retainFullContext', () => {
|
||||
const teamFor = (retainFullContext: boolean) => new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [
|
||||
'find the first fact',
|
||||
'find the second fact',
|
||||
new Task({ name: 'summary', description: 'summarise', retainFullContext }),
|
||||
],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('hands the task every earlier result, not only the most recent one', async () => {
|
||||
await teamFor(true).start();
|
||||
|
||||
const summaryPrompt = prompts()[2];
|
||||
expect(summaryPrompt).toContain('Input data from previous tasks:');
|
||||
// Both upstream results, each labelled with the task that produced it.
|
||||
expect(summaryPrompt).toContain('\ntask_1: reply(find the first fact)');
|
||||
expect(summaryPrompt).toContain('\ntask_2: reply(find the second fact');
|
||||
});
|
||||
|
||||
it('control: without it only the most recent result is carried', async () => {
|
||||
await teamFor(false).start();
|
||||
|
||||
const summaryPrompt = prompts()[2];
|
||||
expect(summaryPrompt).toContain('Here is the input:');
|
||||
// No engine block, and no per-task labels: only the previous answer is carried.
|
||||
expect(summaryPrompt).not.toContain('Input data from previous tasks:');
|
||||
expect(summaryPrompt).not.toContain('\ntask_1: ');
|
||||
expect(summaryPrompt).not.toContain('\ntask_2: ');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// memory / failOnMemoryError / config
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task memory', () => {
|
||||
const store = () => ({
|
||||
add: jest.fn(async (_content: string, _role: string, _metadata?: Record<string, unknown>) => undefined),
|
||||
search: jest.fn(async (_query: string, _limit?: number) => [{ entry: { content: 'whales migrate in winter' }, score: 1 }]),
|
||||
});
|
||||
|
||||
it('recalls into the prompt and writes the answer back', async () => {
|
||||
const memory = store();
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'summarise', description: 'summarise whales', memory })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(prompts()[0]).toContain('whales migrate in winter');
|
||||
expect(memory.search).toHaveBeenCalledTimes(1);
|
||||
expect(memory.add).toHaveBeenCalledTimes(1);
|
||||
expect(memory.add.mock.calls[0][0]).toBe(results[0]);
|
||||
});
|
||||
|
||||
it('control: an unattached store is neither searched nor written', async () => {
|
||||
const memory = store();
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'summarise', description: 'summarise whales' })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(prompts()[0]).not.toContain('whales migrate in winter');
|
||||
expect(memory.search).not.toHaveBeenCalled();
|
||||
expect(memory.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('failOnMemoryError turns a broken store into a failed run', async () => {
|
||||
const broken = {
|
||||
add: jest.fn(async () => { throw new Error('memory backend unreachable'); }),
|
||||
search: jest.fn(async () => []),
|
||||
};
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'summarise', description: 'summarise whales', memory: broken, failOnMemoryError: true })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await expect(team.start()).rejects.toThrow('memory backend unreachable');
|
||||
});
|
||||
|
||||
it('control: the same broken store is a degraded run without the flag', async () => {
|
||||
const broken = {
|
||||
add: jest.fn(async () => { throw new Error('memory backend unreachable'); }),
|
||||
search: jest.fn(async () => []),
|
||||
};
|
||||
const task = new Task({ name: 'summarise', description: 'summarise whales', memory: broken });
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
expect(results[0]).toContain('reply(');
|
||||
expect(task.nonFatalErrors).toContain('store_in_memory: memory backend unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task config', () => {
|
||||
it('config.memory_config gives the task a store, and the run fills it', async () => {
|
||||
const task = new Task({ name: 'note', description: 'note the finding', config: { memory_config: { maxEntries: 5 } } });
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
const results = await team.start();
|
||||
|
||||
const created = task.memory as { getAll(): Array<{ content: string }> };
|
||||
expect(created).toBeDefined();
|
||||
expect(created.getAll().map((entry) => entry.content)).toEqual([results[0]]);
|
||||
});
|
||||
|
||||
it('control: without config the task never acquires a store', async () => {
|
||||
const task = new Task({ name: 'note', description: 'note the finding' });
|
||||
const team = new AgentTeam({ agents: [member()], tasks: [task], ...quiet });
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(task.memory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// knowledge
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task knowledge', () => {
|
||||
it('folds the task s knowledge into its prompt', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'brief', description: 'brief me', knowledge: 'penguins cannot fly' })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(prompts()[0]).toContain('penguins cannot fly');
|
||||
});
|
||||
|
||||
it('searches a knowledge base and folds in what it matched', async () => {
|
||||
const base = { search: jest.fn(async (_q: string, _l?: number) => [{ document: { content: 'the launch slipped to March' } }]) };
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'brief', description: 'brief me', knowledge: base })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(base.search).toHaveBeenCalledTimes(1);
|
||||
expect(prompts()[0]).toContain('the launch slipped to March');
|
||||
});
|
||||
|
||||
it('control: without knowledge nothing is added', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'brief', description: 'brief me' })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(prompts()[0]).not.toContain('penguins cannot fly');
|
||||
expect(prompts()[0].trim()).toContain('brief me');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// images
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task images', () => {
|
||||
it('sends the pictures alongside the text as multimodal content', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'describe', description: 'describe the picture', images: ['https://example.com/cat.png'] })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
const content = mockLlm.calls[0].content as any[];
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content.filter((part) => part.type === 'image_url')).toEqual([
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
||||
]);
|
||||
expect(textOf(content)).toContain('describe the picture');
|
||||
});
|
||||
|
||||
it('control: without images the call carries plain text', async () => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'describe', description: 'describe the picture' })],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
await team.start();
|
||||
|
||||
expect(typeof mockLlm.calls[0].content).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// outputPydantic / outputConfig
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task structured output', () => {
|
||||
const schema = { type: 'object', properties: { title: { type: 'string' } } };
|
||||
|
||||
const runWith = async (config: Record<string, unknown>) => {
|
||||
const seen: any[] = [];
|
||||
mockLlm.responder = () => '```json\n{"title":"Whales"}\n```';
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'shape', description: 'shape it', onTaskComplete: (output) => { seen.push(output); }, ...config })],
|
||||
...quiet,
|
||||
});
|
||||
await team.start();
|
||||
return seen[0];
|
||||
};
|
||||
|
||||
it('outputPydantic parses the answer onto the TaskOutput', async () => {
|
||||
const output = await runWith({ outputPydantic: schema });
|
||||
expect(output.outputFormat).toBe('Pydantic');
|
||||
expect(output.outputPydantic).toEqual({ title: 'Whales' });
|
||||
});
|
||||
|
||||
it('outputConfig reaches the same parsing through its own field', async () => {
|
||||
const output = await runWith({ outputConfig: { pydanticModel: schema } });
|
||||
expect(output.outputFormat).toBe('Pydantic');
|
||||
expect(output.outputPydantic).toEqual({ title: 'Whales' });
|
||||
});
|
||||
|
||||
it('control: neither option leaves the output raw', async () => {
|
||||
const output = await runWith({});
|
||||
expect(output.outputFormat).toBe('RAW');
|
||||
expect(output.outputPydantic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// asyncExecution
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task asyncExecution', () => {
|
||||
const twoTasks = (asyncExecution: boolean) => new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [
|
||||
new Task({ name: 'alpha', description: 'alpha', asyncExecution }),
|
||||
new Task({ name: 'beta', description: 'beta', asyncExecution }),
|
||||
],
|
||||
...quiet,
|
||||
});
|
||||
|
||||
it('runs consecutive async tasks as one batch, at the same time', async () => {
|
||||
mockLlm.delayMs = 10;
|
||||
await twoTasks(true).start();
|
||||
|
||||
expect(mockLlm.maxInFlight).toBe(2);
|
||||
// Running together, neither can have seen the other's answer.
|
||||
expect(prompts()[1]).not.toContain('Here is the input:');
|
||||
});
|
||||
|
||||
it('control: without asyncExecution they run one after the other', async () => {
|
||||
mockLlm.delayMs = 10;
|
||||
await twoTasks(false).start();
|
||||
|
||||
expect(mockLlm.maxInFlight).toBe(1);
|
||||
expect(prompts()[1]).toContain('Here is the input: reply(alpha');
|
||||
});
|
||||
|
||||
it('flushes the batch when a queued task depends on one already in it', async () => {
|
||||
mockLlm.delayMs = 10;
|
||||
const alpha = new Task({ name: 'alpha', description: 'alpha', asyncExecution: true });
|
||||
const beta = new Task({ name: 'beta', description: 'beta', asyncExecution: true });
|
||||
const gamma = new Task({ name: 'gamma', description: 'gamma', asyncExecution: true, dependsOn: [beta] });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [alpha, beta, gamma], ...quiet }).start();
|
||||
|
||||
// alpha+beta overlap; gamma waits, because its dependency was still pending.
|
||||
expect(mockLlm.maxInFlight).toBe(2);
|
||||
expect(prompts()[2]).toContain('Here is the input: reply(beta');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// skipOnFailure
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task skipOnFailure', () => {
|
||||
const teamWith = (skipOnFailure: boolean) => {
|
||||
const upstream = new Task({ name: 'fetch', description: 'fetch', handler: () => { throw new Error('upstream is down'); } });
|
||||
const downstream = new Task({ name: 'report', description: 'report', dependsOn: [upstream], skipOnFailure });
|
||||
return { downstream, team: new AgentTeam({ agents: [member()], tasks: [upstream, downstream], ...quiet }) };
|
||||
};
|
||||
|
||||
it('runs the dependent task anyway when it opted in', async () => {
|
||||
const { downstream, team } = teamWith(true);
|
||||
await team.start();
|
||||
|
||||
expect(prompts()).toHaveLength(1);
|
||||
expect(prompts()[0]).toContain('report');
|
||||
expect(downstream.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('control: without it the dependent task is failed with its dependency', async () => {
|
||||
const { downstream, team } = teamWith(false);
|
||||
await team.start();
|
||||
|
||||
expect(mockLlm.calls).toHaveLength(0);
|
||||
expect(downstream.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// retryDelay
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task retryDelay', () => {
|
||||
const neverSatisfied = { completionChecker: () => false };
|
||||
|
||||
const elapsedFor = async (retryDelay: number): Promise<number> => {
|
||||
const team = new AgentTeam({
|
||||
agents: [member()],
|
||||
tasks: [new Task({ name: 'flaky', description: 'flaky', retryDelay })],
|
||||
hooks: neverSatisfied,
|
||||
execution: { maxRetries: 3 },
|
||||
...quiet,
|
||||
});
|
||||
const started = Date.now();
|
||||
await team.start();
|
||||
return Date.now() - started;
|
||||
};
|
||||
|
||||
it('waits between attempts, doubling the delay each time', async () => {
|
||||
const elapsed = await elapsedFor(0.05);
|
||||
// Three attempts means two waits: 0.05s then 0.10s.
|
||||
expect(mockLlm.calls).toHaveLength(3);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(120);
|
||||
});
|
||||
|
||||
it('control: the default of 0 retries without waiting', async () => {
|
||||
const elapsed = await elapsedFor(0);
|
||||
expect(mockLlm.calls).toHaveLength(3);
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// hooks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task hooks', () => {
|
||||
it('fires the task s own onTaskStart and onTaskComplete', async () => {
|
||||
const onTaskStart = jest.fn();
|
||||
const onTaskComplete = jest.fn();
|
||||
const task = new Task({ name: 'watched', description: 'watched', hooks: { onTaskStart, onTaskComplete } });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [task], ...quiet }).start();
|
||||
|
||||
expect(onTaskStart).toHaveBeenCalledTimes(1);
|
||||
expect(onTaskStart.mock.calls[0][0]).toBe(task);
|
||||
expect(onTaskStart.mock.calls[0][1]).toBe(0);
|
||||
expect(onTaskComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('control: a task without hooks fires nothing', async () => {
|
||||
const onTaskStart = jest.fn();
|
||||
await new AgentTeam({ agents: [member()], tasks: [new Task({ name: 'watched', description: 'watched' })], ...quiet }).start();
|
||||
expect(onTaskStart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// workflow routing: isStart / nextTasks / condition / routing / when
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task workflow routing', () => {
|
||||
it('isStart picks the entry point and nextTasks picks what follows', async () => {
|
||||
const one = new Task({ name: 'one', description: 'step one', isStart: true, nextTasks: ['three'] });
|
||||
const two = new Task({ name: 'two', description: 'step two' });
|
||||
const three = new Task({ name: 'three', description: 'step three' });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [two, one, three], process: 'workflow', ...quiet }).start();
|
||||
|
||||
// `two` is first in the list and is never run: the graph, not the order, decides.
|
||||
expect(prompts().map((p) => p.split('\n')[0])).toEqual(['step one', 'step three']);
|
||||
expect(two.status).toBe('not started');
|
||||
// The edges are also linked back, as Python does before the loop starts.
|
||||
expect(three.previousTasks).toEqual(['one']);
|
||||
expect(two.previousTasks).toEqual([]);
|
||||
});
|
||||
|
||||
it('control: the same tasks run in list order under sequential', async () => {
|
||||
const one = new Task({ name: 'one', description: 'step one', isStart: true, nextTasks: ['three'] });
|
||||
const two = new Task({ name: 'two', description: 'step two' });
|
||||
const three = new Task({ name: 'three', description: 'step three' });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [two, one, three], process: 'sequential', ...quiet }).start();
|
||||
|
||||
expect(prompts().map((p) => p.split('\n')[0])).toEqual(['step two', 'step one', 'step three']);
|
||||
});
|
||||
|
||||
it('when / thenTask / elseTask send control down the branch the answer chose', async () => {
|
||||
const branchFor = async (verdict: string) => {
|
||||
const triage = new Task({
|
||||
name: 'triage',
|
||||
description: 'triage the ticket',
|
||||
isStart: true,
|
||||
when: '{{previous_output}} contains urgent',
|
||||
thenTask: 'escalate',
|
||||
elseTask: 'archive',
|
||||
});
|
||||
const escalate = new Task({ name: 'escalate', description: 'escalate it' });
|
||||
const archive = new Task({ name: 'archive', description: 'archive it' });
|
||||
mockLlm.responder = (_model, prompt) => (prompt.includes('triage the ticket') ? verdict : undefined);
|
||||
await new AgentTeam({ agents: [member()], tasks: [archive, escalate, triage], process: 'workflow', ...quiet }).start();
|
||||
return prompts().map((p) => p.split('\n')[0]);
|
||||
};
|
||||
|
||||
expect(await branchFor('this is urgent')).toEqual(['triage the ticket', 'escalate it']);
|
||||
mockLlm.calls = [];
|
||||
expect(await branchFor('nothing special')).toEqual(['triage the ticket', 'archive it']);
|
||||
});
|
||||
|
||||
it('a decision task s routing table picks the next task, and "exit" ends the run', async () => {
|
||||
const runWith = async (decision: string, table: Record<string, string[]>) => {
|
||||
const gate = new Task({ name: 'gate', description: 'approve or reject', isStart: true, taskType: 'decision', routing: table });
|
||||
const ship = new Task({ name: 'ship', description: 'ship it' });
|
||||
mockLlm.responder = (_model, prompt) => (prompt.includes('approve or reject') ? `{"decision":"${decision}"}` : undefined);
|
||||
await new AgentTeam({ agents: [member()], tasks: [gate, ship], process: 'workflow', ...quiet }).start();
|
||||
return prompts().map((p) => p.split('\n')[0]);
|
||||
};
|
||||
|
||||
const table = { approve: ['ship'], reject: ['exit'] };
|
||||
expect(await runWith('approve', table)).toEqual(['approve or reject', 'ship it']);
|
||||
mockLlm.calls = [];
|
||||
expect(await runWith('reject', table)).toEqual(['approve or reject']);
|
||||
});
|
||||
|
||||
it('condition is the same table under its older name', async () => {
|
||||
const gate = new Task({ name: 'gate', description: 'approve or reject', isStart: true, taskType: 'decision', condition: { approve: ['ship'] } });
|
||||
const ship = new Task({ name: 'ship', description: 'ship it' });
|
||||
mockLlm.responder = (_model, prompt) => (prompt.includes('approve or reject') ? '{"decision":"approve"}' : undefined);
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [gate, ship], process: 'workflow', ...quiet }).start();
|
||||
|
||||
expect(prompts().map((p) => p.split('\n')[0])).toEqual(['approve or reject', 'ship it']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// loopOver / loopVar / loopState
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task loopOver', () => {
|
||||
it('runs the task once per item, binding loopVar and recording loopState', async () => {
|
||||
const states: unknown[] = [];
|
||||
const task = new Task({
|
||||
name: 'greet',
|
||||
description: 'greet {{name}}',
|
||||
loopOver: 'people',
|
||||
loopVar: 'name',
|
||||
onTaskComplete: (_output, metadata) => { states.push(metadata?.loopState); },
|
||||
});
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [task], variables: { people: ['ada', 'bob'] }, ...quiet }).start();
|
||||
|
||||
expect(prompts().map((p) => p.split('\n')[0])).toEqual(['greet ada', 'greet bob']);
|
||||
expect(states).toEqual([
|
||||
{ name: 'ada', index: 0, total: 2 },
|
||||
{ name: 'bob', index: 1, total: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('loopVar defaults to "item"', async () => {
|
||||
const task = new Task({ name: 'greet', description: 'greet {{item}}', loopOver: 'people' });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [task], variables: { people: ['ada', 'bob'] }, ...quiet }).start();
|
||||
|
||||
expect(prompts().map((p) => p.split('\n')[0])).toEqual(['greet ada', 'greet bob']);
|
||||
});
|
||||
|
||||
it('control: without loopOver the task runs once with the placeholder unfilled', async () => {
|
||||
const task = new Task({ name: 'greet', description: 'greet {{name}}' });
|
||||
|
||||
await new AgentTeam({ agents: [member()], tasks: [task], variables: { people: ['ada', 'bob'] }, ...quiet }).start();
|
||||
|
||||
expect(prompts()).toHaveLength(1);
|
||||
expect(prompts()[0]).toContain('greet {{name}}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// inputFile
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Task inputFile', () => {
|
||||
let directory: string;
|
||||
let csv: string;
|
||||
|
||||
beforeAll(() => {
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'praison-task-inputfile-'));
|
||||
csv = path.join(directory, 'rows.csv');
|
||||
fs.writeFileSync(csv, 'Where is Paris?,France\nWhere is Rome?,Italy\n', 'utf8');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fans the task out into one subtask per row of the file', async () => {
|
||||
const task = new Task({ name: 'quiz', description: 'Check the answer', inputFile: csv });
|
||||
|
||||
const results = await new AgentTeam({ agents: [member()], tasks: [task], ...quiet }).start();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(prompts()[0]).toContain('Question: Where is Paris?\nAnswer: France');
|
||||
expect(prompts()[1]).toContain('Question: Where is Rome?\nAnswer: Italy');
|
||||
});
|
||||
|
||||
it('control: without inputFile the task runs once, on its own description', async () => {
|
||||
const task = new Task({ name: 'quiz', description: 'Check the answer' });
|
||||
|
||||
const results = await new AgentTeam({ agents: [member()], tasks: [task], ...quiet }).start();
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(prompts()[0]).not.toContain('Where is Paris?');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user