Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
eb463e3
docs(aiops): design for sub-agent orchestration in the chat agent
kartikmanimuthu Jul 25, 2026
6df725a
docs(aiops): make sub-agent budget controls tenant-configurable
kartikmanimuthu Jul 25, 2026
b287c95
docs(aiops): implementation plan for sub-agent orchestration
kartikmanimuthu Jul 25, 2026
600d271
fix(agent): make AWS session-profile writes concurrency-safe
kartikmanimuthu Jul 25, 2026
a6eddad
feat(agent): per-run LLM timing summary
kartikmanimuthu Jul 25, 2026
007d70a
docs(aiops): fix run-timings eviction defect in the plan
kartikmanimuthu Jul 25, 2026
0e00565
fix(agent): stop run-timing eviction from truncating long-running thr…
kartikmanimuthu Jul 25, 2026
08c7a64
perf(agent): allow batched parallel tool calls in one executor turn
kartikmanimuthu Jul 25, 2026
8c65120
docs(aiops): plan fixes for semaphore fast path and profile-name coll…
kartikmanimuthu Jul 25, 2026
3efb577
fix(agent): make session-profile names unique within the same millise…
kartikmanimuthu Jul 25, 2026
51ff20a
feat(agent): sub-agent budget config with platform ceilings
kartikmanimuthu Jul 25, 2026
500d576
docs(aiops): harden subagent-budget trust boundary in the plan
kartikmanimuthu Jul 25, 2026
6fb173f
fix(agent): type-check before coercing in validateBudgetInput
kartikmanimuthu Jul 25, 2026
d81d98a
feat(api): GET/PUT /api/settings/aiops for sub-agent budget
kartikmanimuthu Jul 25, 2026
41a2446
feat(ui): AI Ops sub-agent budget settings panel
kartikmanimuthu Jul 25, 2026
a84c717
docs(aiops): fix settings-panel defects in the plan
kartikmanimuthu Jul 25, 2026
549ccaa
feat(agent): read-only sub-agent runtime
kartikmanimuthu Jul 25, 2026
5a87b9c
fix(ui): surface invalid-input feedback and correct disabled-state co…
kartikmanimuthu Jul 25, 2026
3c83f74
docs(aiops): make the sub-agent denylist case-insensitive
kartikmanimuthu Jul 25, 2026
0c065d3
fix(agent): case-insensitive denylist in the sub-agent jail
kartikmanimuthu Jul 25, 2026
f7a4107
docs(aiops): size the Save-button spinner via the size variant
kartikmanimuthu Jul 25, 2026
35b1fca
fix(ui): size the Save button Spinner with the size variant, not clas…
kartikmanimuthu Jul 25, 2026
3cadf9d
docs(aiops): replace the sub-agent shell blocklist with an allowlist
kartikmanimuthu Jul 26, 2026
fc1dca6
fix(agent): allowlist sub-agent shell commands and cancel on timeout
kartikmanimuthu Jul 26, 2026
1cbae62
docs(aiops)!: replace sub-agent shell parsing with a structured aws_r…
kartikmanimuthu Jul 26, 2026
5432c31
fix(agent)!: replace sub-agent shell parsing with a structured aws_re…
kartikmanimuthu Jul 26, 2026
ac83356
docs(aiops)!: allowlist aws_read on every axis
kartikmanimuthu Jul 26, 2026
8209eaf
fix(agent)!: allowlist aws_read on every axis
kartikmanimuthu Jul 26, 2026
5695c2c
feat(agent): widen the approved aws_read audit surface
kartikmanimuthu Jul 26, 2026
5295f4d
fix(agent)!: validate aws_read values positively and bind the jail to…
kartikmanimuthu Jul 26, 2026
099ac08
feat(agent): dispatch_agent fan-out tool with per-run budget ledger
kartikmanimuthu Jul 26, 2026
aa3b04c
docs(aiops): make the dispatch_agent progress sink non-throwing
kartikmanimuthu Jul 26, 2026
64ddf02
fix(agent): swallow onSubagentEvent sink errors in dispatch_agent
kartikmanimuthu Jul 26, 2026
8d4c954
feat(chat): stream sub-agent progress as data-subagent parts
kartikmanimuthu Jul 26, 2026
ce209cd
feat(ui): collapsed sub-agent cards in the chat run rail
kartikmanimuthu Jul 26, 2026
facf53b
feat(chat): persist redacted sub-agent transcripts for expansion afte…
kartikmanimuthu Jul 26, 2026
5daa14b
fix(chat): keep the heartbeat writing when no sub-agent is in flight
kartikmanimuthu Jul 26, 2026
d6db4d4
fix(agent): close the three secret-at-rest leaks found by the branch …
kartikmanimuthu Jul 27, 2026
96d293c
fix(agent): make the sub-agent token budget an actual ceiling
kartikmanimuthu Jul 27, 2026
32b87c4
feat(aiops): make all agent behavior settings tenant/UI-driven, no en…
kartikmanimuthu Aug 2, 2026
75758b6
fix(chat): sub-agent stream corruption, console lockdown, and run-rai…
kartikmanimuthu Aug 2, 2026
19c9c18
feat(agent): resource-utilization mandate + delegation/skill prompt d…
kartikmanimuthu Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,35 +90,22 @@ DEFAULT_TENANT_ID=org-default
# Working memory (Phase 1) — in-session context compaction for long-running agents.
# When over WORKING_MEMORY_TOKEN_BUDGET estimated tokens, older turns are folded into
# a rolling summary + scratchpad (via the reflector model) so multi-hour runs survive
# the context window. Set WORKING_MEMORY_ENABLED=false for identical legacy behavior.
WORKING_MEMORY_ENABLED=true
WORKING_MEMORY_TOKEN_BUDGET=60000
WORKING_MEMORY_KEEP_RECENT=8

# Save-time memory reconciliation (Phase 2) — dedup/contradiction resolution via an
# LLM judge before writing. Set false to restore blind upserts (legacy behavior).
MEMORY_RECONCILE_ENABLED=true

# Episodic memory (Phase 3) — capture one distilled episode per tool-using run and
# replay similar past episodes as few-shot experience at task start.
EPISODIC_MEMORY_ENABLED=true

# Procedural memory (Phase 4) — learn operating rules from corrections/failures and
# inject them as '### Operating rules (learned)'; promote matured rules to Skills (human-approved).
PROCEDURAL_MEMORY_ENABLED=true

# Hermes autonomy + observability
# MEMORY_LOG_VERBOSE: print full injected memoryContext blocks (summary lines are always on).
# CHAT_TRIAGE_ENABLED: chat-layer router — conversational messages get a direct reply instead of the agent workflow; the same call auto-picks the skill. false = always run the workflow (legacy behavior).
# (Auto skill selection/loading has no env flag — it is always available and toggled per-session via the "Auto skills" switch in the AIOps console.)
# AUTO_SKILL_CREATION_ENABLED: matured procedural domains auto-become/refresh enabled system skills (read-only tier).
# AUTO_SKILL_MATURITY_THRESHOLD: accessCount a rule needs before promoting.
MEMORY_LOG_VERBOSE=true
CHAT_TRIAGE_ENABLED=true
AUTO_SKILL_CREATION_ENABLED=true
AUTO_SKILL_MATURITY_THRESHOLD=3
# SKILL_SYNTHESIS_MIN_RULES: matured rules a domain needs before it earns a synthesized skill.
SKILL_SYNTHESIS_MIN_RULES=3

# ============================================================================
# Optional integrations
Expand Down Expand Up @@ -148,6 +135,21 @@ LANGFUSE_HOST=https://cloud.langfuse.com
# Skip audit DB writes in local dev
# SKIP_AUDIT_LOGGING=true

# AI Ops sub-agents (Layer B). The feature is UI-driven: each tenant enables it
# and tunes its budget from the AI Ops console settings — no env var needed.
# SUBAGENTS_ENABLED=false is an EMERGENCY deployment-wide kill-switch (tenant
# settings cannot re-enable while it is set); leave it unset in normal operation.
# The SUBAGENT_MAX_* vars are CEILINGS that lower (never raise) the built-in
# maximums a tenant may configure.
# SUBAGENTS_ENABLED=false
# SUBAGENT_MAX_CONCURRENCY=6
# SUBAGENT_MAX_PER_RUN=16
# SUBAGENT_MAX_TOKENS_PER_RUN=1000000
# SUBAGENT_MAX_ITERATIONS=16
# SUBAGENT_TIMEOUT_MS=300000
# Max concurrent shell subprocesses from execute_command (Layer A).
# TOOL_CONCURRENCY=6

# Internal worker<->web-ui auth key (must match across both apps).
# REQUIRED for the agent-ops scheduler to work: the workers process sends this
# as x-internal-key when triggering a scheduled task, and web-ui only trusts the
Expand Down
133 changes: 133 additions & 0 deletions apps/web-ui/app/api/chat/__tests__/stream-parts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest';
import { buildMemoryPart, humanizeReflection, humanizePlanning, isWorkingMemoryPayload, stripWorkingMemoryPrelude } from '@/app/api/chat/stream-parts';
import { buildUsagePart } from '../stream-parts';
import { buildSubagentPart, buildHeartbeatChunks, isSubagentModelEvent } from '../stream-parts';
import { SUBAGENT_MODEL_TAG } from '@/lib/agent/subagent';

const WM_JSON = JSON.stringify({
summary: 'User asked to connect Jira; site resource retrieved.',
Expand Down Expand Up @@ -236,3 +238,134 @@ describe('buildUsagePart', () => {
expect(buildUsagePart(3, 4)).toEqual({ type: 'data-usage', data: { input: 3, output: 4 } });
});
});

describe('buildSubagentPart', () => {
const base = {
id: 'sa-1', role: 'EC2 auditor', task: 'audit account 1',
toolCount: 3, tokensIn: 900, tokensOut: 120,
};

it('builds a stable id so updates replace rather than append', () => {
const part = buildSubagentPart({ ...base, status: 'running' });
expect(part.type).toBe('data-subagent');
expect(part.id).toBe('subagent-sa-1');
});

it('carries progress counters', () => {
const part = buildSubagentPart({ ...base, status: 'running' });
expect(part.data).toMatchObject({ role: 'EC2 auditor', status: 'running', toolCount: 3, tokensIn: 900 });
});

it('omits the transcript from the stream payload', () => {
const part = buildSubagentPart({
...base, status: 'done', summary: 'found things',
transcript: [{ kind: 'ai', text: 'internal reasoning' }],
});
expect(JSON.stringify(part.data)).not.toContain('internal reasoning');
expect((part.data as { summary?: string }).summary).toBe('found things');
});
});

// The heartbeat exists solely to keep bytes flowing so CloudFront's 60s
// originReadTimeout (infra/compute/index.ts:962) can't kill a long run. Its tick
// body lives here — not inline in route.ts — because nothing in the suite imports
// route.ts, so an inline tick is untested by construction.
describe('buildHeartbeatChunks', () => {
const base = {
id: 'sa-1', role: 'EC2 auditor', task: 'audit account 1',
toolCount: 3, tokensIn: 900, tokensOut: 120,
};

it('re-emits one data-subagent part per in-flight sub-agent and no keep-alive', () => {
const chunks = buildHeartbeatChunks([
{ ...base, id: 'sa-1', status: 'running' },
{ ...base, id: 'sa-2', status: 'running' },
]);
expect(chunks).toHaveLength(2);
expect(chunks.map((c) => c.type)).toEqual(['data-subagent', 'data-subagent']);
expect(chunks.map((c: any) => c.id)).toEqual(['subagent-sa-1', 'subagent-sa-2']);
expect(chunks.some((c) => c.type === 'data-keepalive')).toBe(false);
});

// REGRESSION GUARD for F1. `liveSubagents` empties as sub-agents reach their
// terminal event, so a tick that only walks that map writes ZERO bytes during
// the orchestrator's post-fan-out call — the longest silence and the largest
// context in the run, exactly when the 60s origin timeout bites. A tick MUST
// write something. This test fails if the empty-case branch is removed.
it('emits exactly one keep-alive chunk when no sub-agent is in flight', () => {
const chunks = buildHeartbeatChunks([]);
expect(chunks).toHaveLength(1);
expect(chunks[0].type).toBe('data-keepalive');
});

it('marks the keep-alive transient and gives it no sub-agent fields', () => {
const [chunk] = buildHeartbeatChunks([]) as any[];
// transient parts are delivered to the client but never appended to
// message.parts, so a keep-alive cannot bloat the message or move the reducer.
expect(chunk.transient).toBe(true);
expect(chunk.id).toBeUndefined();
expect(chunk.data).toEqual({});
expect(JSON.stringify(chunk)).not.toMatch(/sa-1|EC2 auditor|audit account|transcript/);
});

it('still omits the transcript for a terminal event passed through the tick', () => {
const chunks = buildHeartbeatChunks([{
...base, status: 'done', summary: 'found things',
transcript: [{ kind: 'ai', text: 'internal reasoning' }],
}]);
expect(chunks).toHaveLength(1);
expect(JSON.stringify(chunks[0])).not.toContain('internal reasoning');
expect(JSON.stringify(chunks[0])).not.toContain('transcript');
});
});

// Sub-agent model calls run inside ToolNode, so their on_chat_model_* callback
// events flow through the SAME streamEvents pipeline as the orchestrator's own
// model runs. The route's text-part machinery is a single-threaded state machine;
// N concurrent sub-agent runs driving it interleave part ids, the client receives
// a text-delta for a part that never started, and the AI SDK kills the stream.
// isSubagentModelEvent is the filter that keeps them out.
describe('isSubagentModelEvent', () => {
it('matches chat-model events tagged by the sub-agent runtime', () => {
for (const name of ['on_chat_model_start', 'on_chat_model_stream', 'on_chat_model_end']) {
expect(isSubagentModelEvent({ event: name, tags: ['x', SUBAGENT_MODEL_TAG] })).toBe(true);
}
});

it('ignores untagged chat-model events (the orchestrator itself)', () => {
expect(isSubagentModelEvent({ event: 'on_chat_model_stream', tags: ['seq:step:2'] })).toBe(false);
expect(isSubagentModelEvent({ event: 'on_chat_model_stream' })).toBe(false);
});

it('ignores non-chat-model events even when tagged (tool events must still flow)', () => {
expect(isSubagentModelEvent({ event: 'on_tool_start', tags: [SUBAGENT_MODEL_TAG] })).toBe(false);
expect(isSubagentModelEvent({ event: 'on_chain_stream', tags: [SUBAGENT_MODEL_TAG] })).toBe(false);
});

it('tolerates malformed events', () => {
expect(isSubagentModelEvent({})).toBe(false);
expect(isSubagentModelEvent({ event: undefined, tags: undefined })).toBe(false);
});
});

describe('buildSubagentPart live-detail fields', () => {
const base = {
id: 'sa-1', role: 'EC2 auditor', task: 'audit account 1',
toolCount: 3, tokensIn: 900, tokensOut: 120,
};

it('stamps a ts so the client can derive startedAt from the first part', () => {
const before = Date.now();
const part = buildSubagentPart({ ...base, status: 'running' });
const ts = (part.data as { ts: number }).ts;
expect(ts).toBeGreaterThanOrEqual(before);
expect(ts).toBeLessThanOrEqual(Date.now());
});

it('passes lastTool through for the live action line, omitting it when absent', () => {
const withTool = buildSubagentPart({ ...base, status: 'running', lastTool: 'aws_read (+2 more)' });
expect((withTool.data as { lastTool?: string }).lastTool).toBe('aws_read (+2 more)');
const without = buildSubagentPart({ ...base, status: 'running' });
expect('lastTool' in (without.data as object)).toBe(false);
});
});
Loading
Loading