Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5 793f73786e feat(ts): the team runner actually runs the Task engine, closing 28 options
A merged change built the Task engine -- real, unit-tested implementations
behind 28 Task options. Nothing called it. Grepping the new methods outside the
engine found zero call sites, so passing `handler` or `loopOver` to a Task still
did nothing, and the ledger deliberately kept counting them. A count must not
fall while the behaviour is inert.

AgentTeam now drives it. The run gained a plan phase (loop and input-file
fan-out, back-links, async batching), a routing loop, and a per-task path
applying the engine in Python's order: the rerun gate, the dependency-failure
gate, task hooks, agentConfig resolution, the handler before the model, context
assembly, memory and knowledge recall, images, the cache, retry backoff, the
memory write, and output shaping in place of the inline literal.

All 28 are proved through `team.start()` rather than by calling an engine method
directly, because calling the engine was never the question. 48 tests, each
paired with a control.

Two things worth reading. `taskType: 'decision'` now genuinely changes
behaviour, so its notice is narrowed to the values that still mean nothing
rather than left to cry wolf. And the engine barrel could not be imported:
simple.ts re-exports AgentTeam, so team.ts is on the graph the webview gate
bundles for a browser, and the barrel re-exports two modules that import fs and
path at module scope. That was verified with esbuild before any code was
written; team-runner.ts imports the three builtin-free engine modules directly.
The gate reports zero failures.

The branch was rewritten twice underneath this work, so the wiring was re-applied
hunk by hunk onto the newer file rather than merged. Three fixes that the older
copy predated -- repeated-run reset, the memory-scope offset, and the planning
lifecycle -- were kept as they are on the branch, and no existing test had to
change.

Still open on this surface: autonomy, web, reflection and planning have no
engine implementation to call. Closing them means writing new modules, not
wiring existing ones.

Behaviour parity: 48 options outstanding, now 20.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 23:28:04 +01:00
praisonai-triage-agent[bot]andMervin Praison a75c2a8b2a fix(ts): keep behaviour-parity total at 48 after rebase onto main
Resolve rebase conflict in BEHAVIOUR_PARITY.md and behaviour-parity.json:
main honoured all Agent/Agent.chat options (PR #4841), so the merged
ledger keeps only AgentTeam(8) + Handoff(8) + Task(32) = 48, with 17
partial. Drops the stale Agent/Agent.chat unhonoured sections that the
auto-merge reintroduced.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-05 21:57:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 25a93f5246 fix(ts): correct AgentTeam repeated-run, planning and memory-scope parity
Fixes valid reviewer findings on PR #4837 against the Python reference:

- Per-run state reset (agents.py:_reset_task_state_for_item): start() now
  clears task status/result/assignedAgent so a reused team re-runs its
  tasks instead of skipping stale-"completed" ones (fixes hierarchical
  loop counting prior completions and returning stale results).
- Planning is a one-run replacement (agents.py:_run_with_planning restores
  self.tasks): the configured tasks are saved and restored in a finally,
  so the next run re-plans from the originals.
- Plan todos complete only for steps whose task actually completed, not
  unconditionally, so a refused completion check or an early manager stop
  no longer reports 100%.
- Shared context is scoped per run via a run offset into the context
  manager, so a task is handed only what this run produced.
- Team memory recall filters by userId, so a store shared across users no
  longer leaks another user's entries into the prompt (untagged entries
  stay visible).

Adds regression tests for all four fixes. Retry budget was reviewed and
already matches Python (while retries < max_retries from 0 == max_retries
attempts); left unchanged.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-09-05 21:55:33 +00:00
MervinPraisonandClaude Opus 5 aa80238d2d feat(ts): honour seven AgentTeam settings, and say why six others stay open
AgentTeam accepted fifteen options and acted on none of them. Seven now work;
the other eight are left in the ledger with a reason, because implementing them
would mean diverging from the reference rather than matching it.

Closed:
- memory gives the team one shared store: what it recalls is appended to each
  task prompt in Python's bullet format, and each result is written back.
- context gives the run one ContextManager, so the third task sees everything
  the team produced rather than only the previous result.
- hooks resolves Python's three team callbacks, and a completionChecker that
  refuses an answer drives the retry loop and leaves the task failed.
- execution resolves preset, object or [preset, overrides] into the retry
  ceiling and the manager-loop bound, including Python's floor of 3.
- planning runs plan-first: the plan's steps replace the tasks for the run, with
  Python's approval rule.
- managerLlm with process 'hierarchical' runs the real manager loop, a port of
  Process.hierarchical, including its bounds on invalid picks and re-selections.
- runOn is refused at construction with Python's two messages, because a team
  has no single loop to hand to a runtime. Matching behaviour means throwing.

Left open, each for a stated reason:
- knowledge, guardrails, web, caching, learn, reflection: Python does not apply
  these at team level either. agents.py:736-747 collects them and logs
  "AgentTeam received [...] but does not yet apply them at the team level; pass
  these to individual Agent(...) instances instead." Implementing them here
  would be a divergence, not parity, so the notice now carries Python's own
  explanation.
- autonomy: Python does propagate it, but there is nothing to propagate to.
  autonomy is itself unhonoured on Agent, and Agent exposes no public setter.
  Unblocking it needs an Agent method in the shape of applyDefaultModel.
- toolsRunOn: Python opens one shared sandbox per run and attaches every
  member's tools to it. TypeScript has no compute provider, so anything written
  here would run tools on the host while claiming otherwise.

One divergence declined rather than taken: Python's default completion checker
also fails a task that asked for structured output and did not produce it.
Adopting that would retry then fail every such team task whose model answers in
prose, a default-behaviour change outside this surface. It remains available
through hooks.completionChecker.

Behaviour parity: 76 options outstanding, now 69. The signature report moves
too, because memory, planning and context gained real types in place of unknown.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 21:55:33 +00:00
15 changed files with 3110 additions and 205 deletions

No files matched your search

+11 -41
View File
@@ -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` |
+9 -9
View File
@@ -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 |
+27 -37
View File
@@ -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
}
+16 -16
View File
@@ -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": {
+218
View File
@@ -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';
}
+161
View File
@@ -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 };
}
+230
View File
@@ -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.`
);
}
+117
View File
@@ -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;
}
+192
View File
@@ -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
+5 -1
View File
@@ -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().');
}
+2 -7
View File
@@ -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?');
});
});