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
18 changes: 15 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,21 @@ BETTER_AUTH_SECRET=change_this_dev_secret_before_deploying
BETTER_AUTH_URL=http://localhost:8089

# ── Search retrieval provider (services/search) ─────────────────────────
# pgvector is the default and only implemented backend; llm_wiki/
# google_okf are placeholders (see services/search/README.md).
SEARCH_RETRIEVAL_PROVIDER=pgvector
# llm_wiki is the implemented default (services/search/internal/search
# has no pgvector code path). LLM_WIKI_URL is the canonical env var for
# the LLM Wiki backend (services/llm-wiki, a persistent FastAPI+Postgres
# service implementing POST /api/v1/wiki/compile for literature's write
# path and POST /llmwiki/query for search's read path). In
# docker-compose.yml this is already pointed at the llm-wiki container by
# default; leave it blank for a non-Docker/local run and literature falls
# back to a markdown volume while search falls back to its in-memory QMD
# index instead of calling a remote service.
SEARCH_RETRIEVAL_PROVIDER=llm_wiki
LLM_WIKI_URL=
# Shared bearer token literature/search send to llm-wiki and llm-wiki
# checks incoming requests against. Leave blank for local dev (llm-wiki
# then accepts unauthenticated requests and logs a startup warning) --
# set a real value before deploying anywhere reachable by others.
LLM_WIKI_API_KEY=
GOOGLE_OKF_URL=
GOOGLE_OKF_API_KEY=
Expand All @@ -61,6 +72,7 @@ REPORTS_SERVICE_URL=http://reports:8087
DOCKING_SERVICE_URL=http://docking:8088
AI_SERVICES_URL=http://ai-services:8090
KNOWLEDGE_SERVICE_URL=http://knowledge-service:8091
LLM_WIKI_SERVICE_URL=http://llm-wiki:8092

# ── Frontend ─────────────────────────────────────────────────────────────
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pnpm-debug.log*
.DS_Store
Thumbs.db

# Claude Code local session settings
.claude/

# coverage
coverage/
.nyc_output/
Expand Down
19 changes: 19 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ services:
env_file: .env
environment:
PORT: "8084"
LLM_WIKI_URL: http://llm-wiki:8092
LLM_WIKI_API_KEY: ${LLM_WIKI_API_KEY:-}
ports: ["8084:8084"]
depends_on:
postgres: { condition: service_healthy }
opensearch: { condition: service_healthy }
llm-wiki: { condition: service_started }
networks: [ai-rxos]
restart: unless-stopped

Expand Down Expand Up @@ -147,7 +150,15 @@ services:
build: { context: ., dockerfile: services/literature/Dockerfile }
environment:
PORT: "8082"
LLM_WIKI_URL: http://llm-wiki:8092
LLM_WIKI_API_KEY: ${LLM_WIKI_API_KEY:-}
ports: ["8082:8082"]
# Overrides (not merges with) the x-py-service anchor's depends_on, so
# postgres/redis are repeated here alongside llm-wiki.
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
llm-wiki: { condition: service_started }

kg:
<<: *py-service
Expand Down Expand Up @@ -202,6 +213,14 @@ services:
depends_on:
neo4j: { condition: service_healthy }

llm-wiki:
<<: *py-service
build: { context: ., dockerfile: services/llm-wiki/Dockerfile }
environment:
PORT: "8092"
LLM_WIKI_API_KEY: ${LLM_WIKI_API_KEY:-}
ports: ["8092:8092"]

# ── Frontends ────────────────────────────────────────────────────────
web:
build: { context: ., dockerfile: apps/web/Dockerfile }
Expand Down
75 changes: 74 additions & 1 deletion packages/sdk/src/resources.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
import type {
AgentMemoryEntry,
AgentTask,
ConversationMessage,
DockingResult,
Molecule,
Paginated,
Paper,
Report,
SearchResult,
TenantScope,
} from "@ai-rxos/types";
import type { AiRxOsClient } from "./client";

/** Thin typed wrappers over api-gateway routes. See architecture/03-api-contracts.md. */

export interface ContextItem {
id: string;
title: string;
snippet: string;
source: string;
score: number;
citation: string;
}

export interface ContextRequest extends TenantScope {
query?: string;
embedding?: number[];
topK?: number;
sourceFilters?: string[];
entityFilters?: string[];
conversationId?: string;
agentId?: string;
}

export const search = (client: AiRxOsClient) => ({
query: (q: string, limit = 20) =>
client.get<Paginated<SearchResult>>(`/api/v1/search?q=${encodeURIComponent(q)}&limit=${limit}`),
/** Prompt 8 context-optimization endpoint: compact, cited context instead
* of full documents. See services/search/internal/handlers/context.go. */
context: (req: ContextRequest) =>
client.post<{ items: ContextItem[]; total: number }>(`/api/v1/search/context`, {
query: req.query,
embedding: req.embedding,
top_k: req.topK,
source_filters: req.sourceFilters,
entity_filters: req.entityFilters,
organization_id: req.organizationId,
workspace_id: req.workspaceId,
project_id: req.projectId,
conversation_id: req.conversationId,
agent_id: req.agentId,
}),
});

export const literature = (client: AiRxOsClient) => ({
Expand All @@ -28,11 +65,47 @@ export const molecules = (client: AiRxOsClient) => ({
});

export const agents = (client: AiRxOsClient) => ({
// services/agents exposes POST /api/v1/agents/invoke (see
// services/agents/app/main.py) — not /run, which was a pre-existing SDK/
// service mismatch fixed alongside the memory/conversation additions below.
run: (agentType: string, input: Record<string, unknown>) =>
client.post<AgentTask>(`/api/v1/agents/run`, { agentType, input }),
client.post<AgentTask>(`/api/v1/agents/invoke`, { agentType, input }),
get: (id: string) => client.get<AgentTask>(`/api/v1/agents/tasks/${id}`),
});

/** Agent memory (Prompt 8): store/retrieve/search agent-scoped memory.
* See services/agents/app/routers/memory.py. Tenant scoping is derived
* server-side from the caller's auth token, not from these arguments. */
export const agentMemory = (client: AiRxOsClient) => ({
store: (agentId: string, key: string, value: unknown, opts?: { provenance?: Record<string, unknown>; persistLongTerm?: boolean }) =>
client.post<AgentMemoryEntry>(`/api/v1/agents/memory`, {
agent_id: agentId,
key,
value,
provenance: opts?.provenance ?? {},
persist_long_term: opts?.persistLongTerm ?? false,
}),
retrieve: (agentId: string, key: string) =>
client.get<AgentMemoryEntry>(`/api/v1/agents/memory/${agentId}/${encodeURIComponent(key)}`),
search: (agentId: string, query?: string, limit = 10) =>
client.get<{ items: AgentMemoryEntry[]; total: number }>(
`/api/v1/agents/memory/${agentId}?${query ? `query=${encodeURIComponent(query)}&` : ""}limit=${limit}`,
),
});

/** Conversation memory (Prompt 8). See services/agents/app/routers/conversations.py. */
export const conversations = (client: AiRxOsClient) => ({
addMessage: (conversationId: string, message: Omit<ConversationMessage, "createdAt">) =>
client.post<{ messages: ConversationMessage[] }>(
`/api/v1/agents/conversations/${conversationId}/messages`,
message,
),
getMessages: (conversationId: string, limit?: number) =>
client.get<{ conversationId: string; messages: ConversationMessage[]; total: number }>(
`/api/v1/agents/conversations/${conversationId}/messages${limit ? `?limit=${limit}` : ""}`,
),
});

export const reports = (client: AiRxOsClient) => ({
list: (page = 1) => client.get<Paginated<Report>>(`/api/v1/reports?page=${page}`),
generate: (title: string, type: Report["type"]) =>
Expand Down
68 changes: 67 additions & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,78 @@ export type AgentTask = z.infer<typeof AgentTaskSchema>;
export const SearchResultSchema = z.object({
id: z.string(),
score: z.number(),
source: z.enum(["opensearch", "pgvector", "graph"]),
// "pgvector" is retained for backward compatibility with existing
// callers; services/search's actual retrieval providers are llm_wiki
// (default) and google_okf — see services/search/internal/search/provider.go.
source: z.enum(["opensearch", "pgvector", "llm_wiki", "google_okf", "graph"]),
title: z.string(),
snippet: z.string().optional(),
});
export type SearchResult = z.infer<typeof SearchResultSchema>;

/** Organization/workspace/project scope, mirroring packages/tenancy's
* TENANT_ID_CLAIM convention and services/auth's AuthPayload shape. */
export const TenantScopeSchema = z.object({
organizationId: z.string().optional(),
workspaceId: z.string().optional(),
projectId: z.string().optional(),
});
export type TenantScope = z.infer<typeof TenantScopeSchema>;

/** Canonical metadata attached to every LLM Wiki-indexed document/chunk. */
export const KnowledgeMetadataSchema = z.object({
documentId: z.string(),
sourceType: z.enum([
"paper",
"conference_abstract",
"clinical_trial",
"patent",
"company",
"drug_pipeline",
"kg_derived",
"agent_memory",
"conversation",
]),
sourceId: z.string(),
title: z.string(),
entityIds: z.array(z.string()).default([]),
entityTypes: z.array(z.string()).default([]),
version: z.number().int().positive().default(1),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
provenance: z.record(z.string(), z.unknown()).default({}),
citation: z.record(z.string(), z.unknown()).default({}),
}).merge(TenantScopeSchema);
export type KnowledgeMetadata = z.infer<typeof KnowledgeMetadataSchema>;

/** A chunk of an indexed document, traceable back to its source and tenant. */
export const ChunkSchema = z.object({
chunkId: z.string(),
chunkIndex: z.number().int().nonnegative(),
text: z.string(),
metadata: KnowledgeMetadataSchema,
});
export type Chunk = z.infer<typeof ChunkSchema>;

/** A single entry in an agent's scoped memory (services/agents). */
export const AgentMemoryEntrySchema = z.object({
agentId: z.string(),
key: z.string(),
value: z.unknown(),
provenance: z.record(z.string(), z.unknown()).default({}),
storedAt: z.number().optional(),
}).merge(TenantScopeSchema);
export type AgentMemoryEntry = z.infer<typeof AgentMemoryEntrySchema>;

/** A single conversation turn (services/agents conversation memory). */
export const ConversationMessageSchema = z.object({
role: z.enum(["user", "assistant", "system", "tool"]),
content: z.string(),
metadata: z.record(z.string(), z.unknown()).default({}),
createdAt: z.number().optional(),
});
export type ConversationMessage = z.infer<typeof ConversationMessageSchema>;

export const ReportSchema = z.object({
id: z.string().uuid(),
title: z.string(),
Expand Down
5 changes: 5 additions & 0 deletions services/agents/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class Settings(BaseSettings):
opensearch_url: str = "http://opensearch:9200"
jwt_secret: str = "change_this_dev_secret_before_deploying"

# Canonical LLM Wiki URL (see root .env.example / services/search). When
# unset, long-term agent memory persistence is skipped rather than
# pointed at a service that doesn't exist in this repo's docker-compose.
llm_wiki_url: str | None = None


@lru_cache
def get_settings() -> Settings:
Expand Down
95 changes: 95 additions & 0 deletions services/agents/app/core/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""JWT verification and tenant-scope extraction for the agents service.

services/agents had no auth wiring at all prior to this (every endpoint was
unauthenticated). This mirrors services/literature's app/core/security.py
pattern (same JWT secret convention, same claim names) rather than inventing
a third auth shape — see services/auth/src/tenantContext.ts::AuthPayload for
the canonical {sub, organizationId, roles} claim shape this aligns with.
"""

from __future__ import annotations

from dataclasses import dataclass

import jwt
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.config import get_settings

security = HTTPBearer(auto_error=False)
security_dependency = Security(security)


@dataclass(frozen=True)
class TenantContext:
organization_id: str | None = None
workspace_id: str | None = None
project_id: str | None = None
user_id: str | None = None

def as_dict(self) -> dict[str, str]:
return {
k: v
for k, v in {
"organization_id": self.organization_id,
"workspace_id": self.workspace_id,
"project_id": self.project_id,
"user_id": self.user_id,
}.items()
if v
}


def get_current_user(
credentials: HTTPAuthorizationCredentials | None = security_dependency,
) -> dict[str, str]:
settings = get_settings()
if settings.environment == "test" and credentials is None:
return {"sub": "test-user-fallback"}

if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="authentication required",
)

try:
payload = jwt.decode(
credentials.credentials, settings.jwt_secret, algorithms=["HS256"]
)
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid authentication token",
) from exc

if not isinstance(payload, dict):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid authentication payload",
)
return payload


current_user_dependency = Depends(get_current_user)


def get_tenant_context(
auth_payload: dict[str, str] = current_user_dependency,
) -> TenantContext:
"""Derive the caller's organization/workspace/project scope from the JWT.

This is the only source of truth for scoping memory/conversation reads
and writes — callers must never be able to pass an organization_id/
workspace_id in a request body and have it override this, or one
tenant could read another's agent/conversation memory.
"""
return TenantContext(
organization_id=auth_payload.get("organization_id")
or auth_payload.get("organizationId"),
workspace_id=auth_payload.get("workspace_id")
or auth_payload.get("workspaceId"),
project_id=auth_payload.get("project_id") or auth_payload.get("projectId"),
user_id=auth_payload.get("user_id") or auth_payload.get("sub"),
)
Loading
Loading