feat(ts): honour seven AgentTeam settings, and say why six others stay open (#4837)

* 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]>

* 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]>

* 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]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-05 23:00:56 +01:00
1 parent b9383ee821
commit 354a43b8c4
12 files changed
+1763 -115

No files matched your search

+9 -11
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** |
| **Total** | **48** |
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)
@@ -107,5 +100,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` |
+7 -7
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:74` (ctor `src/praisonai-ts/src/agent/team.ts:332`)
- 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 |
@@ -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:744`
- 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 |
+26 -8
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"
],
@@ -124,5 +142,5 @@
"when"
]
},
"total": 55
"total": 48
}
+13 -13
View File
@@ -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:332",
"location": "src/praisonai-ts/src/agent/team.ts:74"
}
},
"AgentTeam.start": {
@@ -2698,7 +2698,7 @@
}
],
"typescript": {
"location": "src/praisonai-ts/src/agent/team.ts:383",
"location": "src/praisonai-ts/src/agent/team.ts:744",
"options_interfaces": [
"options: AgentTeamStartOptions"
],
+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;
}
+477 -64
View File
@@ -3,14 +3,71 @@ import { getEnv } from '../llm/openaiClientOptions';
import { notYetHonoured, unhonouredFor } from '../utils/parity-notice';
import { Agent, type AgentChatOptions } from './simple';
import type { Task, TaskOutput } from './types';
import type { ContextManager } from '../context/manager';
import type { Plan, TodoList } from '../planning';
import {
assertTeamPlacement,
resolveTeamContext,
resolveTeamExecution,
resolveTeamHooks,
type TeamCompletionChecker,
type TeamContextInput,
type TeamExecutionInput,
type TeamHooksInput,
type TeamTaskCompleteHook,
type TeamTaskRef,
type TeamTaskStartHook,
type TeamTaskStatus,
} from './team-options';
import { createTeamMemory, type TeamMemory, type TeamMemoryInput, type TeamMemoryStore } from './team-memory';
import { runHierarchical, type ManagerTaskView } from './team-manager';
import {
approveTeamPlan,
createTeamPlan,
resolveTeamPlanning,
todoListFromPlan,
type TeamPlanningInput,
type TeamPlanningSettings,
} from './team-planning';
/**
* How an AgentTeam runs its tasks. `workflow` runs like `sequential`;
* `hierarchical` also runs sequentially until a manager agent exists in
* TypeScript (see `managerLlm`).
* `hierarchical` puts a manager agent on `managerLlm` in charge of choosing
* which task runs next and on which member (see {@link AgentTeamConfig.managerLlm}).
*/
export type AgentTeamProcess = 'sequential' | 'parallel' | 'workflow' | 'hierarchical';
/**
* Options this surface accepts for Python parity but does not yet act on are
* listed in `utils/parity-notice.ts`. These are the ones this file DOES act on,
* and are filtered out of the notice loop below. The ledger is shared with the
* other surfaces, so it is the coordinator that deletes these entries; until
* then this set keeps the constructor from warning about behaviour that exists.
*/
const HONOURED_HERE: ReadonlySet<string> = new Set([
'memory', 'context', 'hooks', 'planning', 'execution', 'runOn', 'managerLlm',
]);
/**
* Why each still-unhonoured team option is unhonoured, so the warning says what
* to do instead of only that something was ignored.
*
* The first six are unwired at the team level in Python too -- its constructor
* logs "AgentTeam received [...] but does not yet apply them at the team level;
* pass these to individual Agent(...) instances instead" -- so honouring them
* here would diverge from the reference implementation rather than match it.
*/
const TEAM_OPTION_NOTES: Readonly<Record<string, string>> = {
knowledge: 'Python does not apply it at the team level either; pass knowledge to individual Agent(...) instances.',
guardrails: 'Python does not apply it at the team level either; pass guardrails to individual Agent(...) instances.',
web: 'Python does not apply it at the team level either; pass web to individual Agent(...) instances.',
reflection: 'Python does not apply it at the team level either; pass reflection to individual Agent(...) instances.',
caching: 'Python does not apply it at the team level either; pass caching to individual Agent(...) instances.',
learn: 'Python does not apply it at the team level either; pass learn to individual Agent(...) instances.',
autonomy: 'Python propagates it to members that have none of their own; TypeScript Agents have no autonomy to propagate to yet.',
toolsRunOn: 'It needs one shared sandbox for the whole team; TypeScript has no compute providers yet, so every tool would still run on the host.',
};
/**
* Configuration for multi-agent orchestration
*/
@@ -26,7 +83,12 @@ export interface AgentTeamConfig {
pretty?: boolean;
/** Python parity: process (default "sequential"). */
process?: AgentTeamProcess;
/** Python parity: manager_llm. Model for the hierarchical manager. */
/**
* Python parity: manager_llm. Model for the hierarchical manager: with
* `process: 'hierarchical'` a Manager agent on this model decides which task
* runs next and which member runs it. Defaults to `OPENAI_MODEL_NAME`, then
* `gpt-4o-mini`.
*/
managerLlm?: string;
/** Python parity: name (Optional[str]). */
name?: string;
@@ -39,21 +101,44 @@ export interface AgentTeamConfig {
llm?: string;
/** Python parity: model (Optional[str]). Alias of `llm`; wins when both are given. */
model?: string;
/** Python parity: memory (Optional[Any]). */
memory?: unknown;
/** Python parity: planning (Optional[Any]). */
planning?: unknown;
/** Python parity: context (Optional[Any]). */
context?: unknown;
/**
* Python parity: memory (Optional[Any], default false). One shared memory for
* the whole team: what it recalls is appended to each task's prompt, and each
* task's result is written back. `true` uses an in-process store; pass a
* MemoryConfig, a `.json`/`.jsonl` path, or your own store.
*/
memory?: TeamMemoryInput;
/**
* Python parity: planning (Optional[Any], default false). Plan first: the
* task descriptions become one request, a planner turns it into steps, and
* the steps are what run. `true` uses gpt-4o-mini; a string names the
* planner's model.
*/
planning?: TeamPlanningInput;
/**
* Python parity: context (Optional[Any], default false). One shared
* ContextManager for the run: every task's prompt and result is recorded on
* it, and each task is handed the accumulated context of all the tasks before
* it rather than only the previous result.
*/
context?: TeamContextInput;
/**
* Python parity: output (Optional[Any]). Output preset: "silent" hides the
* per-task result printing, "verbose" forces it.
*/
output?: unknown;
/** Python parity: execution (Optional[Any]). */
execution?: unknown;
/** Python parity: hooks (Optional[Any]). */
hooks?: unknown;
/**
* Python parity: execution (Optional[Any]). Iteration and retry limits, as a
* preset ("fast" | "balanced" | "thorough" | "unlimited"), an object
* (`{ maxIter, maxRetries }`) or `[preset, overrides]`.
*/
execution?: TeamExecutionInput;
/**
* Python parity: hooks (Optional[Any]). Team lifecycle callbacks:
* `onTaskStart`, `onTaskComplete` and `completionChecker` (returning false
* from the checker retries the task).
*/
hooks?: TeamHooksInput;
/** Python parity: autonomy (Optional[Any]). */
autonomy?: unknown;
/** Python parity: knowledge (Optional[Any]). */
@@ -70,7 +155,11 @@ export interface AgentTeamConfig {
learn?: unknown;
/** Python parity: tools_run_on (Optional[Any]). */
toolsRunOn?: unknown;
/** Python parity: run_on (Optional[Any]). */
/**
* Python parity: run_on (Optional[Any]). Refused on a team: `runOn` hands one
* agent's whole loop to a managed runtime, and a team orchestrates several
* agents locally. Passing it throws, as it does in Python.
*/
runOn?: unknown;
}
@@ -119,6 +208,12 @@ interface TeamTask {
options?: AgentChatOptions;
/** The originating Task, when one was given: its status and callbacks are honoured. */
task?: Task;
/** Where this task is in its lifecycle. Tracked here so a plain-string task has one too. */
status: TeamTaskStatus;
/** The last answer this task produced. */
result?: string;
/** The agent a hierarchical manager delegated this task to, overriding the default. */
assignedAgent?: Agent;
}
function looksLikeJsonSchema(value: unknown): value is Record<string, any> {
@@ -168,7 +263,7 @@ export class AgentTeam {
readonly name?: string;
/** Python parity: variables (Optional[Dict[str, Any]]). */
readonly variables?: Record<string, unknown>;
/** Python parity: manager_llm. */
/** Python parity: manager_llm, as supplied. */
readonly managerLlm?: string;
/** Python parity: llm / model. The default model applied to member agents. */
readonly llm?: string;
@@ -200,9 +295,42 @@ export class AgentTeam {
readonly learn?: unknown;
/** Python parity: tools_run_on. */
readonly toolsRunOn?: unknown;
/** Python parity: run_on. */
/** Python parity: run_on. Always undefined: a team refuses the option (see the config docs). */
readonly runOn?: unknown;
/** Python parity: max_iter. The hierarchical manager's iteration ceiling. */
readonly maxIter: number;
/** Python parity: max_retries. Attempts per task before it is left failed (never below 3). */
readonly maxRetries: number;
/** Python parity: on_task_start. Fired before each task runs. */
onTaskStart?: TeamTaskStartHook;
/** Python parity: on_task_complete. Fired after each task completes. */
onTaskComplete?: TeamTaskCompleteHook;
/** Python parity: completion_checker. Returning false retries the task. */
completionChecker: TeamCompletionChecker;
/** Python parity: user_id (default "praison"). */
readonly userId: string;
/** Python parity: shared_memory. The team's shared memory, when `memory` is on. */
readonly sharedMemory?: TeamMemory;
/** Python parity: context_manager. The shared context, when `context` is on. */
readonly contextManager?: ContextManager;
/** Python parity: planning_llm. */
readonly planningLlm: string;
/** Python parity: auto_approve_plan. */
readonly autoApprovePlan: boolean;
private readonly planningSettings: TeamPlanningSettings;
/** Python parity: _current_plan. Set once a planning run has produced a plan. */
private currentPlan?: Plan;
/** Python parity: _todo_list. */
private todoList?: TodoList;
/**
* How many assistant entries the shared context held when this run began, so
* `inputSoFar` hands a task only what THIS run produced (Python resets
* per-task state each run: `_reset_task_state_for_item`).
*/
private contextRunOffset = 0;
/**
* Create a multi-agent orchestration
* @param configOrAgents - Either an array of agents or a config object
@@ -213,6 +341,11 @@ export class AgentTeam {
? { agents: configOrAgents }
: configOrAgents;
// run_on names a place that cannot do the job asked of it. Refuse it here,
// at the call site, rather than accepting it and orchestrating locally
// anyway (Python: resolve_placement(..., supports_run_on=False)).
assertTeamPlacement(config.runOn, config.toolsRunOn);
this.agents = config.agents;
this.name = config.name;
this.variables = config.variables;
@@ -234,7 +367,33 @@ export class AgentTeam {
this.caching = config.caching;
this.learn = config.learn;
this.toolsRunOn = config.toolsRunOn;
this.runOn = config.runOn;
// Kept readable rather than removed: `team.runOn` is always undefined
// because a team never hands its whole loop to a managed runtime.
this.runOn = undefined;
// execution -> iteration and retry limits.
const execution = resolveTeamExecution(config.execution);
this.maxIter = execution.maxIter;
this.maxRetries = execution.maxRetries;
// hooks -> the three team lifecycle callbacks.
const hooks = resolveTeamHooks(config.hooks);
this.onTaskStart = hooks.onTaskStart;
this.onTaskComplete = hooks.onTaskComplete;
this.completionChecker = hooks.completionChecker ?? this.defaultCompletionChecker;
// memory -> one shared store for the whole team.
const memory = createTeamMemory(config.memory);
this.sharedMemory = memory.memory;
this.userId = memory.userId;
// context -> one shared ContextManager for the whole run.
this.contextManager = resolveTeamContext(config.context);
// planning -> plan first, then run the plan's steps.
this.planningSettings = resolveTeamPlanning(config.planning);
this.planningLlm = this.planningSettings.llm;
this.autoApprovePlan = this.planningSettings.autoApprove;
// Default model for member agents constructed without one (Python parity:
// AgentTeam(llm=...) / AgentTeam(model=...); `model` wins).
@@ -250,10 +409,11 @@ export class AgentTeam {
// Accepted for signature parity but not yet honoured on the team surface.
// The ledger in utils/parity-notice.ts is the single list; see it for why.
const accepted: Array<[string, unknown]> = unhonouredFor('AgentTeam.__init__')
.filter((name) => !HONOURED_HERE.has(name))
.map((name) => [name, (config as unknown as Record<string, unknown>)[name]] as [string, unknown]);
for (const [name, value] of accepted) {
if (value !== undefined && value !== false && value !== null) {
notYetHonoured('AgentTeam', name, name === 'managerLlm' ? 'A hierarchical process runs sequentially until a manager agent exists.' : undefined);
notYetHonoured('AgentTeam', name, TEAM_OPTION_NOTES[name]);
}
}
@@ -262,15 +422,55 @@ export class AgentTeam {
? config.tasks.map((task, i) => this.normaliseTask(task, i))
: this.generateTasks();
// The shared context starts from what the members are for, so a task that
// reads it sees who is on the team as well as what they have produced.
if (this.contextManager) {
for (const agent of this.agents) {
const instructions = agent.getInstructions();
if (instructions) this.contextManager.addSystem(`${agent.name}: ${instructions}`);
}
}
// Configure logging
Logger.setVerbose(this.verbose);
Logger.setPretty(this.pretty);
}
/** The team's shared memory store, when `memory` is on. Python parity: shared_memory. */
getSharedMemory(): TeamMemoryStore | undefined {
return this.sharedMemory?.getStore();
}
/** The shared ContextManager, when `context` is on. Python parity: context_manager. */
getContextManager(): ContextManager | undefined {
return this.contextManager;
}
/** The plan a planning run produced, if any. Python parity: _current_plan. */
getPlan(): Plan | undefined {
return this.currentPlan;
}
/** The todo list tracking that plan. Python parity: _todo_list. */
getTodoList(): TodoList | undefined {
return this.todoList;
}
/**
* Python parity: `default_completion_checker`'s freeform branch -- an answer
* with content in it finishes the task. Python additionally fails a task
* closed when structured output was requested and not produced; that rule
* belongs to Task.outputJson rather than to any team option, so it is left
* to `hooks: { completionChecker }` here rather than changed underneath
* existing callers.
*/
private defaultCompletionChecker = (_task: TeamTaskRef, agentOutput: string): boolean =>
agentOutput.trim().length > 0;
/** Turn a prompt string or Task object into a TeamTask, applying `variables`. */
private normaliseTask(task: string | Task, index: number): TeamTask {
if (typeof task === 'string') {
return { name: `task_${index + 1}`, prompt: this.substituteVariables(task) };
return { name: `task_${index + 1}`, prompt: this.substituteVariables(task), status: 'not started' };
}
let prompt = this.substituteVariables(task.description ?? '');
if (task.expected_output) {
@@ -293,33 +493,110 @@ export class AgentTeam {
agent,
options: Object.keys(options).length > 0 ? options : undefined,
task,
status: 'not started',
};
}
/** Run one task on `agent`, keeping the originating Task's status and callbacks in step. */
/** The hook's view of one task. */
private refFor(entry: TeamTask, agent?: Agent): TeamTaskRef {
return {
name: entry.name,
description: entry.task?.description ?? entry.prompt,
status: entry.status,
agent: agent?.name ?? entry.assignedAgent?.name ?? entry.agent?.name,
task: entry.task,
};
}
/**
* Run one task on `agent`, keeping the originating Task's status and
* callbacks in step.
*
* Python parity: `run_task` -- the on_task_start hook, the retry loop bounded
* by `max_retries` and driven by `completion_checker`, the shared-memory
* context and write-back, and the on_task_complete hook.
*/
private async runTask(entry: TeamTask, agent: Agent, prompt: string, previousResult?: string): Promise<string> {
const index = this.tasks.indexOf(entry);
entry.task?.markStarted();
let result: string;
try {
result = await agent.start(prompt, previousResult, undefined, undefined, undefined, entry.options);
} catch (error) {
entry.task?.markFailed();
throw error;
}
if (entry.task) {
const output: TaskOutput = { description: entry.task.description, raw: result, agent: agent.name, outputFormat: 'RAW' };
if (entry.options?.outputJson || entry.options?.outputPydantic) {
try {
const parsed = JSON.parse(result);
if (entry.options.outputJson) { output.outputJson = parsed; output.outputFormat = 'JSON'; }
else { output.outputPydantic = parsed; output.outputFormat = 'Pydantic'; }
} catch {
// Not JSON: the raw text stands.
}
entry.status = 'in progress';
if (this.onTaskStart) {
try {
await this.onTaskStart(this.refFor(entry, agent), index);
} catch (error) {
await Logger.error(`Error in onTaskStart callback: ${(error as Error)?.message ?? String(error)}`);
}
entry.task.markCompleted(output);
await entry.task.notifyComplete(output);
}
// Shared memory is context, not history: what it recalls is appended to the
// prompt exactly as Python appends it, as bare bullet lines.
let finalPrompt = prompt;
if (this.sharedMemory) {
const recalled = await this.sharedMemory.recall(prompt);
if (recalled) finalPrompt = `${prompt}\n\n${recalled}`;
}
let result = '';
let completed = false;
const attempts = Math.max(1, this.maxRetries);
for (let attempt = 0; attempt < attempts; attempt++) {
try {
result = await agent.start(finalPrompt, previousResult, undefined, undefined, undefined, entry.options);
} catch (error) {
entry.status = 'failed';
entry.task?.markFailed();
throw error;
}
if (this.completionChecker(this.refFor(entry, agent), result)) {
completed = true;
break;
}
await Logger.debug(`Task ${entry.name}: completion check failed (attempt ${attempt + 1}/${attempts})`);
}
entry.result = result;
entry.status = completed ? 'completed' : 'failed';
const output: TaskOutput = {
description: entry.task?.description ?? entry.prompt,
raw: result,
agent: agent.name,
outputFormat: 'RAW',
};
if (entry.options?.outputJson || entry.options?.outputPydantic) {
try {
const parsed = JSON.parse(result);
if (entry.options.outputJson) { output.outputJson = parsed; output.outputFormat = 'JSON'; }
else { output.outputPydantic = parsed; output.outputFormat = 'Pydantic'; }
} catch {
// Not JSON: the raw text stands.
}
}
if (entry.task) {
if (completed) {
entry.task.markCompleted(output);
await entry.task.notifyComplete(output);
} else {
entry.task.markFailed();
}
}
if (this.sharedMemory && completed) await this.sharedMemory.remember(prompt, result, entry.name);
if (this.contextManager) {
this.contextManager.add(prompt, 'user', { metadata: { task: entry.name } });
if (result) this.contextManager.add(result, 'assistant', { metadata: { task: entry.name, agent: agent.name } });
}
if (this.onTaskComplete) {
try {
await this.onTaskComplete(this.refFor(entry, agent), output);
} catch (error) {
await Logger.error(`Error in onTaskComplete callback: ${(error as Error)?.message ?? String(error)}`);
}
}
return result;
}
@@ -336,13 +613,13 @@ export class AgentTeam {
const instructions = agent.getInstructions();
// Extract task from instructions - get first sentence or whole instruction if no period
const task = instructions.split('.')[0].trim();
return { name: `task_${i + 1}`, prompt: task };
return { name: `task_${i + 1}`, prompt: task, status: 'not started' as TeamTaskStatus };
});
}
/** The agent that runs task `i`: the Task's own agent, else the i-th (or last) team member. */
/** The agent that runs task `i`: a manager's pick, the Task's own agent, else the i-th (or last) member. */
private agentFor(task: TeamTask, index: number): Agent {
return task.agent ?? this.agents[Math.min(index, this.agents.length - 1)];
return task.assignedAgent ?? task.agent ?? this.agents[Math.min(index, this.agents.length - 1)];
}
/** `content` (Python parity) is added to every task's context. */
@@ -350,6 +627,20 @@ export class AgentTeam {
return content ? `${prompt}\n\nContext: ${content}` : prompt;
}
/**
* What a task is handed as "the input so far". With a shared ContextManager
* that is everything the team has produced; without one it is the previous
* task's result, as before.
*/
private inputSoFar(previousResult?: string): string | undefined {
if (!this.contextManager) return previousResult;
const produced = this.contextManager.getByRole('assistant')
.slice(this.contextRunOffset)
.map((item) => item.content)
.filter(Boolean);
return produced.length > 0 ? produced.join('\n\n') : undefined;
}
private async executeSequential(content?: string): Promise<string[]> {
const results: string[] = [];
let previousResult: string | undefined;
@@ -362,9 +653,10 @@ export class AgentTeam {
await Logger.debug(`Task: ${task.prompt}`);
// For first agent, use task directly
// For subsequent agents, append previous result to their instructions
// For subsequent agents, append the input so far to their instructions
const base = this.withContent(task.prompt, content);
const prompt = i === 0 ? base : `${base}\n\nHere is the input: ${previousResult}`;
const input = this.inputSoFar(previousResult);
const prompt = i === 0 || !input ? base : `${base}\n\nHere is the input: ${input}`;
const result = await this.runTask(task, agent, prompt, previousResult);
results.push(result);
previousResult = result;
@@ -373,6 +665,97 @@ export class AgentTeam {
return results;
}
/**
* Run the tasks under a manager on `managerLlm` (Python parity:
* `Process.hierarchical`). The manager chooses the order and the member;
* a task it never reaches keeps an empty result.
*/
private async executeHierarchical(content?: string): Promise<string[]> {
const managerLlm = this.managerLlm ?? getEnv('OPENAI_MODEL_NAME') ?? 'gpt-4o-mini';
const agentNames = this.agents.map((agent) => agent.name);
const outcome = await runHierarchical({
managerLlm,
maxIter: this.maxIter,
verbose: this.verbose,
agentNames,
snapshot: (): ManagerTaskView[] => this.tasks.map((task, index) => ({
task_id: index,
name: task.name,
description: task.task?.description ?? task.prompt,
status: task.status,
agent: this.agentFor(task, index)?.name ?? 'No agent',
})),
assignAgent: (taskId, agentName) => {
const picked = this.agents.find((agent) => agent.name === agentName);
if (picked) this.tasks[taskId].assignedAgent = picked;
},
runTask: async (taskId) => {
const entry = this.tasks[taskId];
const agent = this.agentFor(entry, taskId);
const base = this.withContent(entry.prompt, content);
const input = this.inputSoFar(taskId > 0 ? this.tasks[taskId - 1].result : undefined);
const prompt = input ? `${base}\n\nHere is the input: ${input}` : base;
await this.runTask(entry, agent, prompt);
},
});
await Logger.debug(`Hierarchical process ended: ${outcome}`);
return this.tasks.map((task) => task.result ?? '');
}
/**
* Planning mode (Python parity: `_run_with_planning`). The task descriptions
* become one request, the planner turns it into steps, and -- once approved
* -- the steps replace the tasks for this run. Returns false when the plan
* was rejected, which abandons the run; null-ish planning falls through to
* normal execution.
*/
private async applyPlanning(): Promise<'aborted' | 'skipped' | 'planned'> {
const request = this.tasks.map((task) => task.task?.description ?? task.prompt).join(' AND ');
const plan = await createTeamPlan(this.planningSettings, request, this.verbose);
if (!plan) {
await Logger.warn('Planning produced no steps; falling back to normal execution.');
return 'skipped';
}
this.currentPlan = plan;
if (!(await approveTeamPlan(this.planningSettings, plan))) {
await Logger.warn('Plan rejected. Aborting execution.');
return 'aborted';
}
plan.start();
this.todoList = todoListFromPlan(plan);
const byName = new Map(this.agents.map((agent) => [agent.name, agent]));
const original = this.tasks;
this.tasks = plan.steps.map((step, index) => {
const named = typeof step.metadata?.agent === 'string' ? byName.get(step.metadata.agent) : undefined;
const inherited = original[Math.min(index, original.length - 1)];
return {
name: `Plan Step ${index + 1}`,
prompt: step.description,
agent: named ?? this.agents[0],
options: inherited?.options,
status: 'not started' as TeamTaskStatus,
};
});
return 'planned';
}
/**
* Clear per-run task state so a reused team starts fresh (Python parity:
* `_reset_task_state_for_item` -- without it a task seen `completed` from the
* previous run is skipped and its stale result is returned).
*/
private resetTaskState(): void {
for (const task of this.tasks) {
task.status = 'not started';
task.result = undefined;
task.assignedAgent = undefined;
}
}
/**
* Run every task.
*
@@ -391,30 +774,60 @@ export class AgentTeam {
await Logger.debug('Process mode:', this.process);
await Logger.debug('Tasks:', this.tasks.map((t) => t.prompt));
let results: string[];
// Fresh run: clear stale status/results so a reused team does not skip
// already-"completed" tasks, and scope the shared context to this run so a
// task is handed only what this run has produced.
const savedTasks = this.tasks;
this.resetTaskState();
this.contextRunOffset = this.contextManager?.getByRole('assistant').length ?? 0;
if (this.process === 'parallel') {
// Run all tasks in parallel
const promises = this.tasks.map((task, i) => this.runTask(task, this.agentFor(task, i), this.withContent(task.prompt, content)));
results = await Promise.all(promises);
} else {
// sequential (default), workflow, and hierarchical (no manager yet) run in order
results = await this.executeSequential(content);
}
if (printResults) {
await Logger.info('AgentTeam execution completed.');
for (let i = 0; i < results.length; i++) {
await Logger.section(`Result from Agent ${i + 1}`, results[i]);
try {
if (this.planningSettings.enabled) {
const outcome = await this.applyPlanning();
if (outcome === 'aborted') return returnDict ? {} : [];
}
}
if (returnDict) {
const byTask: Record<string, string> = {};
this.tasks.forEach((task, i) => { byTask[task.name] = results[i]; });
return byTask;
let results: string[];
if (this.process === 'parallel') {
// Run all tasks in parallel
const promises = this.tasks.map((task, i) => this.runTask(task, this.agentFor(task, i), this.withContent(task.prompt, content)));
results = await Promise.all(promises);
} else if (this.process === 'hierarchical') {
results = await this.executeHierarchical(content);
} else {
// sequential (default) and workflow run in order
results = await this.executeSequential(content);
}
// Only tasks that actually completed mark their plan todo done: a
// completion checker can leave one failed, and a hierarchical run can
// stop or exhaust its bound before every step ran.
if (this.todoList) {
this.tasks.forEach((task, i) => {
if (task.status === 'completed') this.todoList!.items[i]?.complete();
});
}
if (printResults) {
await Logger.info('AgentTeam execution completed.');
for (let i = 0; i < results.length; i++) {
await Logger.section(`Result from Agent ${i + 1}`, results[i]);
}
}
if (returnDict) {
const byTask: Record<string, string> = {};
this.tasks.forEach((task, i) => { byTask[task.name] = results[i]; });
return byTask;
}
return results;
} finally {
// Planning replaces the tasks for one run only; restore the configured
// tasks so the next run re-plans from the originals (Python parity:
// `_run_with_planning` -> `self.tasks = original_tasks`).
this.tasks = savedTasks;
}
return results;
}
async chat(content?: string, options?: AgentTeamStartOptions): Promise<string[]>;
+1 -2
View File
@@ -34,8 +34,7 @@
*/
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',
@@ -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 () => {