Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-2392%20passing-brightgreen" alt="2392 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-2407%20passing-brightgreen" alt="2407 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (2392 passing tests across 280 files with passing tests) | Unit and integration tests |
| Testing | Vitest (2407 passing tests across 281 files with passing tests) | Unit and integration tests |
| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
Expand Down Expand Up @@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# Passing-test count: 2392 test cases across 280 files (skipped excluded)
# Passing-test count: 2407 test cases across 281 files (skipped excluded)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1197,7 +1197,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (2392 passing, 280 files with passes; skipped excluded)
pnpm test # Run all tests (2407 passing, 281 files with passes; skipped excluded)
pnpm lint # ESLint
```

Expand Down
119 changes: 119 additions & 0 deletions apps/api/src/modules/agent/agent-autopilot-tiers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import {
autopilotRiskTier,
shouldAutoExecuteDecision,
MONEY_AUTOPILOT_FLOOR,
} from './agent-autopilot-tiers';

describe('autopilotRiskTier', () => {
it('classifies money-moving agents', () => {
expect(autopilotRiskTier('pricing')).toBe('money');
expect(autopilotRiskTier('overbooking')).toBe('money');
expect(autopilotRiskTier('channel_mix')).toBe('money');
expect(autopilotRiskTier('revenue_manager')).toBe('money');
expect(autopilotRiskTier('group_pickup')).toBe('money');
});

it('classifies guest-facing agents', () => {
expect(autopilotRiskTier('guest_comms')).toBe('guest');
expect(autopilotRiskTier('review_response')).toBe('guest');
});

it('classifies remaining agents as ops', () => {
expect(autopilotRiskTier('demand_forecast')).toBe('ops');
expect(autopilotRiskTier('night_audit')).toBe('ops');
expect(autopilotRiskTier('housekeeping')).toBe('ops');
expect(autopilotRiskTier('cancellation')).toBe('ops');
expect(autopilotRiskTier('ar_collections')).toBe('ops');
expect(autopilotRiskTier('deposit_risk')).toBe('ops');
});
});

describe('shouldAutoExecuteDecision', () => {
it('never auto-executes outside autopilot mode', () => {
expect(
shouldAutoExecuteDecision({
mode: 'suggest',
agentType: 'pricing',
confidence: 0.99,
configThreshold: 0.85,
}),
).toBe(false);
});

it('never auto-executes guest-facing agents even at high confidence', () => {
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'review_response',
confidence: 0.99,
configThreshold: 0.5,
}),
).toBe(false);
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'guest_comms',
confidence: 0.99,
configThreshold: 0.5,
}),
).toBe(false);
});

it('requires money agents to clear the money floor even if config is lower', () => {
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'pricing',
confidence: 0.9,
configThreshold: 0.85,
}),
).toBe(false);
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'pricing',
confidence: MONEY_AUTOPILOT_FLOOR,
configThreshold: 0.85,
}),
).toBe(true);
});

it('respects a config threshold above the money floor', () => {
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'overbooking',
confidence: 0.93,
configThreshold: 0.95,
}),
).toBe(false);
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'overbooking',
confidence: 0.95,
configThreshold: 0.95,
}),
).toBe(true);
});

it('uses the config threshold alone for ops agents', () => {
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'demand_forecast',
confidence: 0.85,
configThreshold: 0.85,
}),
).toBe(true);
expect(
shouldAutoExecuteDecision({
mode: 'autopilot',
agentType: 'night_audit',
confidence: 0.84,
configThreshold: 0.85,
}),
).toBe(false);
});
});
50 changes: 50 additions & 0 deletions apps/api/src/modules/agent/agent-autopilot-tiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Autopilot risk tiers — one confidence number is not equal across agents.
* Money-moving writes need a harder floor; guest-facing drafts never auto-run.
*/

export type AutopilotRiskTier = 'money' | 'ops' | 'guest';

const MONEY_AGENTS = new Set([
'pricing',
'overbooking',
'channel_mix',
'revenue_manager',
'group_pickup',
]);

const GUEST_AGENTS = new Set(['guest_comms', 'review_response']);

/** Floor applied on top of the property's configured autopilot threshold for money agents. */
export const MONEY_AUTOPILOT_FLOOR = 0.92;

export function autopilotRiskTier(agentType: string): AutopilotRiskTier {
if (MONEY_AGENTS.has(agentType)) return 'money';
if (GUEST_AGENTS.has(agentType)) return 'guest';
return 'ops';
}

/**
* Whether autopilot may auto-execute this recommendation.
* Guest-facing agents always return false (human approve).
* Money agents require confidence >= max(configThreshold, MONEY_AUTOPILOT_FLOOR).
* Ops agents use the config threshold as today.
*/
export function shouldAutoExecuteDecision(input: {
mode: string;
agentType: string;
confidence: number;
configThreshold: number;
}): boolean {
if (input.mode !== 'autopilot') return false;

const tier = autopilotRiskTier(input.agentType);
if (tier === 'guest') return false;

const threshold =
tier === 'money'
? Math.max(input.configThreshold, MONEY_AUTOPILOT_FLOOR)
: input.configThreshold;

return input.confidence >= threshold;
}
133 changes: 133 additions & 0 deletions apps/api/src/modules/agent/agent.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,139 @@ describe('AgentService', () => {
);
});

it('does not auto-execute money agents below the 0.92 floor in autopilot', async () => {
const autopilotConfig = {
...existingConfig,
mode: 'autopilot',
autopilotConfidenceThreshold: '0.85',
};
const insertedDecision = {
id: 'dec-money',
decisionType: 'rate_adjustment',
confidence: '0.90',
status: 'pending',
};
const db = createMockDb({
selectResult: [autopilotConfig],
insertResult: [insertedDecision],
});
const execute = vi.fn().mockResolvedValue({ success: true, changes: [] });
const agent = createMockAgent('pricing');
agent.recommend = async () => [
{
decisionType: 'rate_adjustment',
recommendation: { delta: 10 },
confidence: 0.9,
inputSnapshot: {},
},
];
agent.execute = execute;

const module = await Test.createTestingModule({
providers: [
AgentService,
{ provide: DRIZZLE, useValue: db },
{ provide: WebhookService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
{ provide: LlmService, useValue: { explain: vi.fn().mockResolvedValue(null) } },
],
}).compile();
const service = module.get(AgentService);
service.registerAgent(agent);

const result = await service.runAgent('prop-1', 'pricing');
expect(execute).not.toHaveBeenCalled();
expect((result as any).decisions[0].status).toBe('pending');
});

it('never auto-executes review_response even at high confidence in autopilot', async () => {
const autopilotConfig = {
...existingConfig,
agentType: 'review_response',
mode: 'autopilot',
autopilotConfidenceThreshold: '0.50',
};
const insertedDecision = {
id: 'dec-review',
decisionType: 'review_response',
confidence: '0.99',
status: 'pending',
};
const db = createMockDb({
selectResult: [autopilotConfig],
insertResult: [insertedDecision],
});
const execute = vi.fn().mockResolvedValue({ success: true, changes: [] });
const agent = createMockAgent('review_response');
agent.recommend = async () => [
{
decisionType: 'review_response',
recommendation: { responseText: 'Thanks' },
confidence: 0.99,
inputSnapshot: {},
},
];
agent.execute = execute;

const module = await Test.createTestingModule({
providers: [
AgentService,
{ provide: DRIZZLE, useValue: db },
{ provide: WebhookService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
{ provide: LlmService, useValue: { explain: vi.fn().mockResolvedValue(null) } },
],
}).compile();
const service = module.get(AgentService);
service.registerAgent(agent);

await service.runAgent('prop-1', 'review_response');
expect(execute).not.toHaveBeenCalled();
});

it('auto-executes ops agents at the configured threshold in autopilot', async () => {
const autopilotConfig = {
...existingConfig,
agentType: 'demand_forecast',
mode: 'autopilot',
autopilotConfidenceThreshold: '0.85',
};
const insertedDecision = {
id: 'dec-ops',
decisionType: 'forecast',
confidence: '0.85',
status: 'pending',
};
const db = createMockDb({
selectResult: [autopilotConfig],
insertResult: [insertedDecision],
updateResult: [{ ...insertedDecision, status: 'auto_executed' }],
});
const execute = vi.fn().mockResolvedValue({ success: true, changes: [] });
const agent = createMockAgent('demand_forecast');
agent.recommend = async () => [
{
decisionType: 'forecast',
recommendation: { summary: 'ok' },
confidence: 0.85,
inputSnapshot: {},
},
];
agent.execute = execute;

const module = await Test.createTestingModule({
providers: [
AgentService,
{ provide: DRIZZLE, useValue: db },
{ provide: WebhookService, useValue: { emit: vi.fn().mockResolvedValue(undefined) } },
{ provide: LlmService, useValue: { explain: vi.fn().mockResolvedValue(null) } },
],
}).compile();
const service = module.get(AgentService);
service.registerAgent(agent);

await service.runAgent('prop-1', 'demand_forecast');
expect(execute).toHaveBeenCalledOnce();
});

// --- approveDecision ---

it('rejects approval of non-pending decision', async () => {
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/modules/agent/agent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isValidAgentType,
subgraphFor,
} from './agent-graph';
import { shouldAutoExecuteDecision } from './agent-autopilot-tiers';

@Injectable()
export class AgentService {
Expand Down Expand Up @@ -119,8 +120,13 @@ export class AgentService {
const threshold = parseFloat(config.autopilotConfidenceThreshold ?? '0.85');

for (const rec of recommendations) {
const shouldAutoExecute =
config.mode === 'autopilot' && rec.confidence >= threshold;
// Risk-tiered autopilot: money needs a harder floor; guest drafts never auto-run.
const shouldAutoExecute = shouldAutoExecuteDecision({
mode: config.mode,
agentType,
confidence: rec.confidence,
configThreshold: threshold,
});

// Always insert as pending first — update to auto_executed only on success
const [decision] = await this.db
Expand Down
Loading
Loading