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
14 changes: 14 additions & 0 deletions .changeset/vnext-pluggable-seams.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@reaatech/agent-mesh': minor
'@reaatech/agent-mesh-classifier': minor
'@reaatech/agent-mesh-session': minor
'@reaatech/agent-mesh-utils': minor
---

agent-mesh v-next groundwork — provider-agnostic, backend-agnostic, and host-embeddable, all additive and backward-compatible:

- **classifier:** pluggable `ClassifierProvider` (+ `createClassifier`) — inject any intent classifier (self-hosted, a different provider, or a host-resolved model) instead of the hard-wired Gemini classifier. Default (no provider) is unchanged: Gemini with a mock fallback.
- **session / utils:** pluggable `SessionStore` and `BreakerStore` with Firestore as the default implementation, dependency-free `InMemory*` adapters, and injection via `setSessionStore` / `setBreakerStore`. The exported service/module functions delegate to the active store; signatures unchanged. (Postgres/Redis adapters to follow.)
- **core:** optional `metadata` passthrough on `IncomingRequest` / `ContextPacket` / `SessionRecord` so an embedding host can carry its own identifiers (e.g. a multi-tenant `orgId`) without the HR-specific `employee_id`; plus the `SessionStore` / `BreakerStore` / `LeaderState` interfaces.

No breaking changes: the default behaviour (Gemini classifier, Firestore persistence, no metadata) is byte-for-byte unchanged.
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@vitest/coverage-v8": "^4.1.5",
"turbo": "^2.9.16",
"typescript": "^6.0.3",
"vite": ">=8.0.16",
"vitest": "^4.1.5"
},
"pnpm": {
Expand All @@ -35,9 +36,12 @@
],
"overrides": {
"fast-uri": ">=3.1.2",
"hono": ">=4.12.18",
"hono": ">=4.12.25",
"ip-address": ">=10.1.1",
"protobufjs": ">=8.0.2"
"protobufjs": ">=8.4.1",
"@grpc/grpc-js": ">=1.14.4",
"ws": ">=8.21.0",
"form-data": ">=4.0.6"
}
},
"engines": {
Expand Down
22 changes: 22 additions & 0 deletions packages/classifier/src/classifier.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ClassifierOutput } from '@reaatech/agent-mesh';
import type { AgentRegistry } from '@reaatech/agent-mesh-registry';

/**
* Pluggable intent classifier.
*
* Implement this to supply an alternative to the built-in Gemini classifier —
* e.g. a self-hosted model, a different provider, or a host-resolved model
* (letting the embedding application, rather than agent-mesh, own the inference
* call). Both the built-in `GeminiClassifier` and `MockClassifier` implement it.
*
* Inject via `new ClassifierService(provider)` or {@link createClassifier}.
* `classify` may be synchronous or asynchronous — `ClassifierService` awaits the
* result either way.
*/
export interface ClassifierProvider {
classify(
userInput: string,
registry: AgentRegistry,
priorLanguage?: string,
): ClassifierOutput | Promise<ClassifierOutput>;
}
35 changes: 27 additions & 8 deletions packages/classifier/src/classifier.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ClassifierOutput } from '@reaatech/agent-mesh';
import { env } from '@reaatech/agent-mesh';
import { logger } from '@reaatech/agent-mesh-observability';
import type { AgentRegistry } from '@reaatech/agent-mesh-registry';
import type { ClassifierProvider } from './classifier.provider.js';
import { detectLanguage } from './localization.js';
import { buildClassifierPrompt, parseClassifierOutput } from './prompt.builder.js';

Expand Down Expand Up @@ -35,7 +36,7 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

class MockClassifier {
class MockClassifier implements ClassifierProvider {
classify(userInput: string, registry: AgentRegistry): ClassifierOutput {
const input = userInput.toLowerCase();
const detectedLanguage = detectLanguage(userInput);
Expand Down Expand Up @@ -77,7 +78,7 @@ class MockClassifier {
}
}

class GeminiClassifier {
class GeminiClassifier implements ClassifierProvider {
private model: unknown = null;
private initialized = false;

Expand Down Expand Up @@ -165,20 +166,30 @@ class GeminiClassifier {
}

export class ClassifierService {
private geminiClassifier: GeminiClassifier | null = null;
private primary: ClassifierProvider | null = null;
private mockClassifier: MockClassifier;
private useMock = false;

constructor() {
/**
* @param provider Optional classifier used in place of the built-in Gemini
* classifier. When omitted, behaviour is unchanged: Gemini in normal runs
* (with a mock fallback on failure), mock under `NODE_ENV=test`. When
* provided, the injected classifier is the primary and the mock remains the
* on-error fallback — and it is used regardless of `NODE_ENV` (so it is
* testable).
*/
constructor(provider?: ClassifierProvider) {
this.mockClassifier = new MockClassifier();

if (env.NODE_ENV !== 'test') {
if (provider) {
this.primary = provider;
} else if (env.NODE_ENV !== 'test') {
const gemini = new GeminiClassifier();
gemini.init().catch(() => {
logger.info('Using mock classifier (Gemini unavailable)');
this.useMock = true;
});
this.geminiClassifier = gemini;
this.primary = gemini;
} else {
this.useMock = true;
}
Expand All @@ -190,11 +201,11 @@ export class ClassifierService {
priorLanguage?: string,
): Promise<ClassifierOutput> {
try {
if (this.useMock || !this.geminiClassifier) {
if (this.useMock || !this.primary) {
return this.mockClassifier.classify(userInput, registry);
}

return await this.geminiClassifier.classify(userInput, registry, priorLanguage);
return await this.primary.classify(userInput, registry, priorLanguage);
} catch (error) {
const msg = error instanceof Error ? error.message : 'unknown';
logger.warn('Classifier falling back to mock', { error: msg });
Expand All @@ -208,4 +219,12 @@ export class ClassifierService {
}
}

/**
* Create a {@link ClassifierService}, optionally backed by a custom
* {@link ClassifierProvider}. Equivalent to `new ClassifierService(provider)`.
*/
export function createClassifier(provider?: ClassifierProvider): ClassifierService {
return new ClassifierService(provider);
}

export const classifierService = new ClassifierService();
30 changes: 29 additions & 1 deletion packages/classifier/src/classifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AgentRegistry } from '@reaatech/agent-mesh-registry';
import { describe, expect, it } from 'vitest';
import { classifierService, detectLanguage, isRateLimitError } from './index.js';
import type { ClassifierProvider } from './index.js';
import { classifierService, createClassifier, detectLanguage, isRateLimitError } from './index.js';

describe('@reaatech/agent-mesh-classifier', () => {
it('should export classifier singleton', () => {
Expand All @@ -14,4 +16,30 @@ describe('@reaatech/agent-mesh-classifier', () => {
it('should detect non-rate-limit errors', () => {
expect(isRateLimitError(new Error('some error'))).toBe(false);
});

it('routes through an injected ClassifierProvider', async () => {
const stub: ClassifierProvider = {
classify: () => ({
agent_id: 'stub-agent',
confidence: 0.99,
ambiguous: false,
detected_language: 'en',
intent_summary: 'from stub',
entities: {},
}),
};
const svc = createClassifier(stub);
const out = await svc.classify('anything', [] as unknown as AgentRegistry);
expect(out.agent_id).toBe('stub-agent');
expect(out.confidence).toBe(0.99);
expect(svc.isMock()).toBe(false);
});

it('falls back to the mock (default agent) when no provider is injected', async () => {
const registry = [
{ agent_id: 'default-agent', display_name: 'Default', is_default: true, examples: ['hello'] },
] as unknown as AgentRegistry;
const out = await createClassifier().classify('some unmatched text', registry);
expect(out.agent_id).toBe('default-agent');
});
});
8 changes: 7 additions & 1 deletion packages/classifier/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { ClassifierService, classifierService, isRateLimitError } from './classifier.service.js';
export type { ClassifierProvider } from './classifier.provider.js';
export {
ClassifierService,
classifierService,
createClassifier,
isRateLimitError,
} from './classifier.service.js';
export {
detectLanguage,
FALLBACK_QUESTIONS,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const IncomingRequestSchema = z.object({
locale: z.string().optional(),
user_id: z.string().optional(),
slack_user_id: z.string().optional(),
// Host-defined passthrough (e.g. multi-tenant `orgId`). agent-mesh does not
// interpret it; it rides through ContextPacket/SessionRecord to the agent.
metadata: z.record(z.string(), z.unknown()).optional(),
});

export type IncomingRequest = z.infer<typeof IncomingRequestSchema>;
Expand Down Expand Up @@ -70,6 +73,7 @@ export const ContextPacketSchema = z.object({
)
.default([]),
workflow_state: z.record(z.string(), z.unknown()).default({}),
metadata: z.record(z.string(), z.unknown()).optional(),
});

export type ContextPacket = z.infer<typeof ContextPacketSchema>;
Expand Down Expand Up @@ -115,6 +119,7 @@ export const SessionRecordSchema = z.object({
workflow_state: z.record(z.string(), z.unknown()).default({}),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
metadata: z.record(z.string(), z.unknown()).optional(),
ttl: z.date(),
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './constants.js';
export * from './domain.js';
export * from './env.js';
export * from './stores.js';
50 changes: 50 additions & 0 deletions packages/core/src/stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { CircuitBreakerState, SessionRecord, SessionStatus, TurnEntry } from './domain.js';

/**
* Persistence seam for multi-turn sessions. The built-in Firestore implementation
* is the default; alternative backends (Postgres, Redis, in-memory) implement this
* interface and are injected via the session package's `setSessionStore`.
*
* Policy (TTL, max-turns) is passed in by the caller so each backend owns only the
* atomic read-modify-write, not the business rules.
*/
export interface SessionStore {
create(record: SessionRecord): Promise<void>;
get(sessionId: string): Promise<SessionRecord | null>;
/** Active, non-expired session for a user, if any. */
getActiveByUser(userId: string): Promise<SessionRecord | null>;
/** Append a turn atomically, trimming to `maxTurns` and refreshing TTL by `ttlMs`. */
appendTurn(
sessionId: string,
turn: TurnEntry,
opts: { maxTurns: number; ttlMs: number },
): Promise<void>;
updateWorkflowState(sessionId: string, workflowState: Record<string, unknown>): Promise<void>;
/** Seed an existing session's history/state (used when resuming a prior session). */
seed(
sessionId: string,
data: { turn_history: TurnEntry[]; workflow_state: Record<string, unknown> },
): Promise<void>;
close(sessionId: string, status: Exclude<SessionStatus, 'active'>): Promise<void>;
}

/** Result of a leadership-acquisition attempt (used by circuit-breaker sync). */
export interface LeaderState {
isLeader: boolean;
leaderId: string;
lastHeartbeat: number;
leaseExpiresAt: number;
}

/**
* Persistence seam for circuit-breaker state + leader election. The built-in
* Firestore implementation is the default; alternative backends implement this
* interface and are injected via the utils package's `setBreakerStore`.
*/
export interface BreakerStore {
load(agentId: string): Promise<CircuitBreakerState | null>;
loadAll(): Promise<Map<string, CircuitBreakerState>>;
persist(state: CircuitBreakerState, instanceId: string): Promise<void>;
/** Attempt to acquire/renew the sync leadership lease for `instanceId`. */
acquireLeadership(instanceId: string, leaseMs: number): Promise<LeaderState>;
}
8 changes: 8 additions & 0 deletions packages/session/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,11 @@ export {
resumeSession,
updateWorkflowState,
} from './session.service.js';
export {
FirestoreSessionStore,
getSessionStore,
InMemorySessionStore,
resetSessionStore,
type SessionStore,
setSessionStore,
} from './session.store.js';
Loading
Loading