feat: deep-agent canvas, live observability, and multi-environment tooling

Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
nihalashetty
2026-07-28 01:49:19 +05:30
commit ae67bff5a3
350 changed files with 58244 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "web", "dev"],
"port": 3000
},
{
"name": "web-dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "web", "exec", "next", "dev"],
"autoPort": true,
"port": 3000
}
]
}
+20
View File
@@ -0,0 +1,20 @@
**/node_modules
**/.next
**/.venv
**/.data
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.ruff_cache
**/.mypy_cache
**/*.egg-info
**/.git
**/*.db
**/*.sqlite
**/.env
**/.env.local
**/.DS_Store
# The api/worker builds now use context '.', so the old apps/api/.dockerignore is dead (Docker
# reads only the .dockerignore at the context root). Keep tests out of the api image (nothing
# at runtime imports them); explicit path avoids over-matching a top-level 'tests'.
apps/api/tests
+47
View File
@@ -0,0 +1,47 @@
# Forge — environment config. Copy to `.env` at the repo ROOT (gitignored) and set real values.
# All backend vars are prefixed FORGE_. This file holds PLACEHOLDERS ONLY — never commit real secrets.
# --- Core ---
FORGE_ENVIRONMENT=development # "production" enforces the checklist at the bottom
# --- Platform auth ---
FORGE_JWT_SECRET=dev-insecure-change-me # MUST be a strong random value in production
# FORGE_AUTH_REQUIRED=true # default true — keep true so the service token is a real gate
FORGE_BOOTSTRAP_ADMIN_EMAIL=you@forge.local
FORGE_BOOTSTRAP_ADMIN_PASSWORD=forge-admin # change for production
# --- Server-to-server barrier (an app backend → Forge) ---
# Static bearer that authenticates a trusted backend as a least-privilege service identity.
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
FORGE_SERVICE_API_TOKEN=
# --- SSRF egress: allow specific private/loopback hosts (dev/testing only) ---
FORGE_EGRESS_ALLOW_PRIVATE_HOSTS=[] # e.g. ["localhost","127.0.0.1"] (Docker: ["host.docker.internal"])
# --- Tools: per-environment values ---
# A JSON map exposed to REST/GraphQL tool + auth templates as {{env.*}}, so the SAME tool row
# resolves to a different host per deploy (dev/qa/prod). A template referencing a key NOT in this
# map fails the call loudly. Blank/unset = {}.
FORGE_TOOL_VARS= # e.g. {"api_base":"https://api.example.com"}
# Deployment-wide fallback for a per-user auth provider's `token_ctx_key`: the run-context key an
# integration forwards its per-user token under (via X-Forge-Context) when a provider doesn't set
# its own. Empty = off. e.g. user_token
FORGE_DEFAULT_TOKEN_CTX_KEY=
# --- Model (needed for live agent runs) ---
FORGE_DEFAULT_MODEL=fake:echo # offline-safe; set a real model for live runs, e.g. gpt-4.1-mini
# OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
# --- Frontend ---
NEXT_PUBLIC_FORGE_API_URL=http://localhost:8000
# ============================================================================
# PRODUCTION (Docker stack) — uncomment/set when deploying:
# POSTGRES_PASSWORD=<db-password> # compose substitutes this into the DB URL
# FORGE_DATABASE_URL=postgresql+psycopg://forge:<pw>@localhost:5432/forge
# FORGE_CHECKPOINT_BACKEND=postgres
# FORGE_REDIS_URL=redis://localhost:6379/0
# The app REFUSES to boot in production unless: strong FORGE_JWT_SECRET,
# FORGE_AUTH_REQUIRED=true, FORGE_EGRESS_BLOCK_PRIVATE=true, Postgres FORGE_DATABASE_URL.
# ============================================================================
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug report
about: Report something that isn't working as expected
title: "[bug] "
labels: bug
---
**Describe the bug**
A clear and concise description of what's wrong.
**To reproduce**
Steps to reproduce the behavior:
1. Go to '…'
2. Configure '…'
3. Run '…'
4. See error
**Expected behavior**
What you expected to happen.
**Screenshots / logs**
If applicable, add screenshots or relevant log output. **Redact any secrets, tokens, or
customer data.**
**Environment**
- Forge version / commit SHA:
- Deployment: local (SQLite) / Docker (Postgres) / other
- OS & browser (for console issues):
- Python / Node version (for local dev):
**Additional context**
Anything else that might help — workflow/tool config (redacted), provider, etc.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 🔒 Report a security vulnerability
url: https://github.com/nihalashetty/Forge/security/advisories/new
about: Please report vulnerabilities privately via a GitHub Security Advisory, not a public issue.
- name: 💬 Questions & discussion
url: https://github.com/nihalashetty/Forge/discussions
about: Ask questions and share ideas with the community.
+19
View File
@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea or improvement
title: "[feat] "
labels: enhancement
---
**What problem are you trying to solve?**
A clear description of the use case or pain point. "I'm always frustrated when …"
**Proposed solution**
What you'd like Forge to do.
**Alternatives considered**
Other approaches or workarounds you've thought about.
**Additional context**
Mockups, links, related nodes/tools, or examples. Check the
[roadmap](../../docs/ROADMAP.md) first — it may already be planned.
+27
View File
@@ -0,0 +1,27 @@
<!-- Thanks for contributing to Forge! Please fill this out to speed up review. -->
## What & why
<!-- What does this change do, and what problem does it solve? Link the issue: Closes #123 -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Performance
- [ ] Refactor (no behavior change)
- [ ] Docs / chore
## Checklist
- [ ] Backend: `ruff check forge migrations` is clean and `pytest -q` passes (from `apps/api`)
- [ ] New/changed backend code is `mypy`-clean
- [ ] Frontend: `pnpm --filter web build` passes (from repo root)
- [ ] Shared schemas (`packages/schemas`) updated if node/tool config changed
- [ ] Tests added/updated for the change (characterization test for behavior-preserving refactors)
- [ ] `CHANGELOG.md` updated under **Unreleased** (for user-facing changes)
- [ ] No secrets, tokens, or customer data in the diff
## Notes for reviewers
<!-- Anything that needs special attention, screenshots for UI changes, migration notes, etc. -->
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Backend Python dependencies (PEP 621 pyproject.toml).
- package-ecosystem: "pip"
directory: "/apps/api"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
python:
patterns: ["*"]
# Frontend / workspace JS dependencies (pnpm workspace at the repo root).
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
javascript:
patterns: ["*"]
# GitHub Actions used by the workflows.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
+51
View File
@@ -0,0 +1,51 @@
name: CI
on:
push:
branches: [main]
pull_request:
# Least privilege: CI only reads the checked-out code; it never writes back to the repo.
permissions:
contents: read
jobs:
api:
name: API - lint + tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/api
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,all]"
- name: Ruff
run: ruff check forge migrations
- name: Mypy (advisory — gradual typing, see CONTRIBUTING.md)
continue-on-error: true
run: mypy forge
- name: Pytest
run: pytest -q
web:
name: Web - typecheck + build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 9
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile || pnpm install
- name: Build
run: pnpm --filter web build
+34
View File
@@ -0,0 +1,34 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # weekly, Monday 06:00 UTC
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
language: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Analyze
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
+44
View File
@@ -0,0 +1,44 @@
# --- Python ---
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# --- Node / Next ---
node_modules/
.next/
out/
dist/
build/
*.tsbuildinfo
.turbo/
# --- Forge runtime data & secrets ---
apps/api/.data/
*.db
*.sqlite
*.sqlite3
.env
.env.local
master.key
# --- Editors / OS / tooling ---
.vscode/
.idea/
.DS_Store
Thumbs.db
# --- Claude Code / agent tooling (local dev only; not part of the product) ---
# Keep .claude/launch.json (the preview config) tracked; ignore everything else.
.agents/
.claude/*
!.claude/launch.json
# --- WebFetch / temp artifacts ---
*.bin
# --- Local architecture notes (not part of the product) ---
/PENDING_FEATURES.md
+168
View File
@@ -0,0 +1,168 @@
# Changelog
All notable changes to Forge are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Deep agents on canvas, live observability & multi-environment tools
- **Deep-agent sub-agents on the canvas** (new): a Deep Agent node gains a third **subagents** handle —
drag it to any specialist agent node to fold that node in as a callable sub-agent (rendered as a
dashed org-chart branch). The compiler folds each wired agent into the supervisor's `subagents`
(name/description/system_prompt/tools/model), and the deep agent is now built as `create_agent` +
only the opt-in deepagents middleware you enable (planning / filesystem / sub-agents / skills)
instead of the full `create_deep_agent` harness — a concise task-tool prompt replaces deepagents'
~536-token essay, cutting per-supervisor-turn cost. Skills stay wired via `SkillsMiddleware`.
- **Live agent-activity + named sub-agent traces** (new): the tracer records a deep-agent `task`
dispatch as a named `subagent · <name>` span (kind `subagent`) instead of a generic `tool · task`,
and streams a per-tool/per-sub-agent **activity** timeline over SSE. The Playground shows this live
("Agent activity"), the canvas Test panel lights up folded sub-agent nodes, and the **Traces** view
is now a **collapsible span tree** (real `parent_span_id` hierarchy, friendly canvas node names,
per-kind colored dots, collapse/expand-all).
- **Per-environment tool values `{{env.*}}`** (new): a `FORGE_TOOL_VARS` JSON map is exposed to
REST/GraphQL tool + auth endpoint templates as `{{env.*}}`, so the SAME tool row resolves to a
different host per deploy (dev/qa/prod). Missing keys **fail loud** (never a broken URL); `{{ctx.*}}`
stays lenient.
- **Protected, auto-provisioned built-ins**: the platform built-ins (time, calculator, web
fetch/search, knowledge search, `remember`/`recall`) are provisioned into every project on create
and on tools-list read, pinned to the top of the Tools screen, and **cannot be deleted** (409). They
are excluded from import/export bundles, so importing a project neither duplicates nor loses them.
- **Import / export now carries tool sets**: a tool bundle includes the tool sets grouping its tools;
import re-creates them (remapping ids, auto-renaming on collision) and reports the count.
- **Live-streamed evaluations**: dataset runs stream `start` / `item` / `done` SSE frames — every case
renders immediately and resolves live with per-case status, latency, and tokens, plus a progress bar.
- **Console runs act as the logged-in operator**: Playground and canvas-test runs are attributed to
the signed-in user (removing the manual "Acting as" box), so per-user auth providers resolve the
operator's own connected credential — the same on-behalf-of path evals now use.
- **`FORGE_DEFAULT_TOKEN_CTX_KEY`** (new): a deployment-wide fallback for a per-user auth provider's
`token_ctx_key`, so an integration that always forwards its per-user token under one context key
works for every provider without per-provider configuration.
- **Fixed:** OpenAI streamed runs now report token usage / cost (`stream_usage`), instead of 0; a HITL
turn with several approval-gated tool calls resumes correctly (one decision replicated to each
hanging call) in both workflow runs and the Forge Assistant; the MCP endpoint shown on the Deploy
screen points at the API host directly so OAuth discovery works.
### Tool sets, MCP server, governance & portability
- **Tool sets** (new): a describable, many-to-many group of tools that does two jobs at once —
it organizes the Tools screen (folders/filter chips) *and* is the unit of assignment and
exposure. Grant an agent a whole set (`agent.config.toolsets`, resolved to member tools at
compile time) and publish a set as a GitHub-style **MCP toolset**.
- **Per-project MCP server — full transport + auth.** The exposed server now speaks native
**Streamable-HTTP / SSE** (Claude Desktop, Cursor, VS Code connect **directly — no `mcp-remote`
bridge**), with the legacy request/response JSON-RPC POST preserved for simple clients; both
share one auth + tool-resolution core. Three ways to authenticate: a shared **project API key**
(server-to-server, no identity), a per-user **personal access token** (PAT, `forge_pat_…`), and
optional **OAuth 2.1** (Dynamic Client Registration + PKCE S256, audience-bound tokens;
default-off behind `FORGE_MCP_OAUTH_ENABLED`). The exposed surface is exactly the enabled tools
of **exposed tool sets**; knowledge, Q&A, and a whole workflow can also be published as MCP
tools. A least-privileged **connector** role can self-serve MCP tokens and call tools but sees
no projects/settings.
- **Per-user identity over MCP + connected credentials.** A project-scoped session token or PAT
resolves to an `end_user`, threaded into the run so entitlement gating and `{{ctx.*}}` injection
act per user. An OAuth auth-provider can key its token bundle **per end user**
(`per_user_context_keys`); the app owner stores each user's bundle via the new **connections API**
(`PUT/GET/DELETE /v1/projects/{id}/auth-providers/{apId}/connections/{endUserId}`). No MCP token
is ever passed downstream — Forge holds a separate per-user credential.
- **Guardrails & Egress** (new): a single project-level I/O policy in **Settings → Guardrails &
Egress** (admin-gated), enforced **by default on every agent** — no per-agent wiring. Content
guardrails (PII redaction, custom `Label = regex` patterns, blocked terms with
redact/mask/hash/block/flag) run locally in-process; the network egress policy (block-private +
allow/deny domain lists) is applied to every REST/GraphQL tool, webhook, `web_fetch`, and SQL
host. A project may only **tighten** inherited egress, never loosen it.
- **Import / Export (portability)** (new): export **tools, workflows, components, and agents** to a
portable `forge.bundle/1` JSON file and import them into another project. Secret **values never
leave** (only `secret://…` references travel; import warns you to recreate them); imports **never
overwrite** (new ids, auto-rename on name collision) and remap in-bundle references. Available
from each list screen's toolbar; import requires the `editor` role.
- **Model catalog from the backend**: the provider/model list is served by the API as one source
of truth (was duplicated in the web app).
### Changed (console & runtime)
- **Console reskin**: a shadcn-style **neutral + indigo** design system and a minimal sidebar nav;
Traces now read like a chat history; unified screen headings and bare icons app-wide.
- **Performance:** cut interactive chat latency by eliminating a ~4s cold-connection DNS (AAAA)
stall on outbound LLM/REST calls and reusing pooled LLM connections
(`FORGE_PREFER_IPV4_EGRESS`, default on).
### Fixed (this cycle)
- Map `openai_moderation` middleware flags to `langchain-openai >=1.3`.
- Group a HITL pause+resume into a single trace turn, and stop recording a HITL interrupt as a
span error.
- Classifier sends one bounded human turn for cross-provider compatibility; the agent binds at
most one tool per function name.
### Feature-bounty fixes (correctness, governance, and DX)
- **Entity version history** (new): every save of a workflow/agent/tool/component/auth-provider/
knowledge-source/project snapshots to `entity_versions`; view + restore in the console; retention
pruned to a configurable `version_history_limit`.
- **Engine correctness:** Loop nodes no longer crash past ~8 iterations (run `recursion_limit` is
set); many previously-ignored node/middleware options now work (Join reducer, parallel
isolation/ordering/timeout, tenant-budget USD cap + per-run token scope, guardrail
apply_to/redact/flag, model_retry retry_on, subworkflow input/output mapping, Transform jq, LLM
`{{state}}` templating, agent-node dynamic prompt/model); validation now errors on undeclared
state-key writes + branches-without-condition.
- **RAG grounding:** default relevance floor calibrated to the local embedder (0.18 → 0.6),
thresholds the true cosine in hybrid mode + a rerank floor (so off-topic → "I don't know"),
chunk citations, per-page crawl provenance (+robots/limits), MMR, resumable batched ingest.
- **Isolation/privacy:** per-user long-term memory scope; response-cache keyed by tenant/user/auth;
Postgres RLS actually wired (per-transaction tenant GUC); MCP `stdio` gated + external-MCP SSRF
screening; tool-I/O trace redaction on by default in production.
- **Reliability:** webhook + `/run` idempotency; scheduler on by default with an atomic
double-fire-safe claim; HITL TOCTOU + chained-interrupt + timeout fixes; outbound channel retry
with delivery status; run cancellation; transient-only tool retries.
- **Observability:** OTel export fixed (wall-clock times + real span hierarchy); cost accounting
handles prompt-cache tiers + dated/unlisted models; retriever/embedding spans; evals gain
concurrency, persisted history + regression gate, more scorers, robust judge.
- **Platform/governance:** per-project RBAC + scoped revocable API keys; auth rate-limiting,
refresh rotation, logout, password-reset/verify, optional TOTP MFA; project budgets +
allowed-models enforcement; scheduled retention purge; audit pagination/export + secret.read;
fail-closed public rate limiter; extended hardening guard; workspace management; worker DLQ.
- **MCP:** exposed-server rate-limited + per-project tool allow-list; expose a whole workflow as
an MCP tool (`mcp_expose_workflow`).
- **Semantic caching** wired as an agent middleware (was built but unreachable).
- **Console:** Settings redesigned with a section sidebar (incl. a model-pricing editor); a
restrained de-colored palette; version-history drawer; canvas unsaved-changes guard + undo/redo
+ copy-paste; Playground Stop + real thread reset; Deep Agent config panel.
### Added
- Project developer meta: `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, this
changelog, and GitHub issue/PR templates.
- Lightweight `GET /v1/projects/{id}/counts` endpoint powering the sidebar badge counts.
- In-flight GET de-duplication in the web API client (collapses duplicate concurrent
requests into one).
- Characterization tests for the stats rollups (`apps/api/tests/test_stats.py`).
- Static type-checking with `mypy` (advisory in CI; gradual adoption) and a `CodeQL`
workflow + Dependabot for supply-chain updates.
### Changed
- **Performance:** dashboard and project stats now compute rollups as SQL aggregates
(`COUNT`/`SUM` + `GROUP BY`) instead of loading a tenant's entire trace history into
memory. Output is unchanged.
- **Performance:** the project sidebar fetches one counts call instead of six full lists;
the dashboard fetches its stats once (was twice); Overview reuses counts.
- **Performance:** the Traces view loads conversations 20 at a time with infinite scroll
instead of fetching the entire history at once.
- Typed API responses for the counts and stats endpoints (`response_model`), improving the
generated OpenAPI schema.
- Pinned `ruff` to a reproducible range so CI lint doesn't drift with new rule sets.
### Fixed
- Documentation drift: backend README layout, root README architecture description,
`TECH_STACK.md` embedder entry, and roadmap chunking strategies.
- Pre-existing lint findings (import order, statement style, mutable `ContextVar` default).
### Removed
- Dead code: an orphaned frontend screen and a half-wired "code" workflow node (frontend
palette entry + orphan schema with no backend registration).
- Committed local agent tooling that was not part of the product.
## [0.1.0]
- Initial Forge platform: visual agent/workflow builder on LangChain + LangGraph, tools
(REST/GraphQL/Code/SQL/MCP/built-in), knowledge & RAG, generative-UI components,
embeddable widget, channels, triggers, evaluations, observability/traces, and a
production-shaped Docker stack. See the [README](README.md) and [ROADMAP](docs/ROADMAP.md).
[Unreleased]: https://github.com/nihalashetty/Forge/compare/main...HEAD
+42
View File
@@ -0,0 +1,42 @@
# Code of Conduct
## Our commitment
We want Forge to be a welcoming, harassment-free community for everyone, regardless of
experience level, background, or identity. We are committed to a respectful and
constructive environment for all contributors and users.
## Expected behavior
- Be respectful, considerate, and collaborative.
- Welcome newcomers and assume good intent.
- Give and gracefully accept constructive feedback; critique ideas, not people.
- Focus on what is best for the community and the project.
## Unacceptable behavior
- Harassment, insults, or derogatory comments; personal or political attacks.
- Discriminatory language or imagery, or unwelcome sexual attention.
- Publishing others' private information without explicit permission.
- Sustained disruption of discussions, issues, or reviews.
## Scope
This code applies in all project spaces — the repository, issues, pull requests,
discussions — and when an individual is representing the project in public spaces.
## Enforcement
Instances of abusive or unacceptable behavior may be reported to the maintainers privately
via a [GitHub Security Advisory](https://github.com/nihalashetty/Forge/security/advisories/new)
or by contacting a maintainer directly. All reports will be reviewed and investigated
promptly and fairly, and reporter confidentiality will be respected.
Maintainers may take any action they deem appropriate, up to and including a temporary or
permanent ban from the community, and will communicate the reasons for moderation
decisions when appropriate.
---
This Code of Conduct is adapted in spirit from the
[Contributor Covenant](https://www.contributor-covenant.org/).
+74
View File
@@ -0,0 +1,74 @@
# Contributing to Forge
Thanks for your interest in improving Forge! This guide covers how to get set up, the
checks your change needs to pass, and our conventions.
## Ways to contribute
- **Report bugs** and **request features** via [issues](https://github.com/nihalashetty/Forge/issues) (templates provided).
- **Improve docs** — the [User Manual](docs/MANUAL.md), READMEs, and `TECH_STACK.md`.
- **Fix or build** — pick up an open issue or propose a change in a discussion first for anything large.
## Development setup
See the [README quick start](README.md#quick-start-local-zero-infra) for the full local
(zero-infra) setup. In short:
```bash
# Backend (FastAPI engine)
cd apps/api
python -m venv .venv && source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -e ".[dev,all]"
# Frontend (Next.js console) — from the repo root
pnpm install
pnpm --filter web dev
```
The local stack runs on SQLite + embedded Chroma + an in-process scheduler — no Docker,
Postgres, or Redis required. See [`apps/api/README.md`](apps/api/README.md) for the backend
layout and production swaps.
## Before you open a pull request
Run the same checks CI runs:
**Backend** (from `apps/api`):
```bash
ruff check forge migrations # lint (pinned; must be clean)
mypy forge # type check (advisory today — keep new/changed code clean)
pytest -q # tests must pass
```
**Frontend** (from the repo root):
```bash
pnpm --filter web build # typecheck + build
```
Additional expectations:
- **Keep the shared schemas authoritative.** `packages/schemas` is the single source of
truth for node/tool config; the backend validator, the compiler, and the frontend
`<SchemaForm>` all read from it. Update the schema, not one consumer.
- **Add tests** for new behavior. For refactors that must preserve behavior, add a
characterization test first (see `apps/api/tests/test_stats.py` for the pattern).
- **Type-checking is gradual.** `mypy` is advisory in CI while we clear a backlog on the
older engine modules, but any file you add or substantially change should be mypy-clean.
## Commit & PR conventions
- **Conventional Commits** for messages: `feat:`, `fix:`, `perf:`, `chore:`, `docs:`,
`refactor:`, `test:` — optionally scoped, e.g. `perf(stats): …`.
- **Atomic commits** — one logical change per commit; keep history bisectable.
- Open a PR describing the change and the reasoning. Link the issue it addresses.
- Update [`CHANGELOG.md`](CHANGELOG.md) under **Unreleased** for anything user-facing.
## Versioning
Forge follows [Semantic Versioning](https://semver.org/). User-facing changes are recorded
in the changelog and rolled into the next release.
## License
By contributing, you agree that your contributions are licensed under the
[MIT License](LICENSE), the same license as the project.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nihal A Shetty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+238
View File
@@ -0,0 +1,238 @@
<div align="center">
# Forge
**The open-source, self-hosted platform for visually building, testing, and shipping AI agents & workflows.**
Wire agents, tools, knowledge, and logic on a canvas - ground them in your data, connect them to your systems, and deploy to email, an API, an MCP server, or an embeddable web widget. No framework code required.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.113.13-3776AB?logo=python&logoColor=white)](apps/api/pyproject.toml)
[![Node](https://img.shields.io/badge/Node-22%20LTS-339933?logo=nodedotjs&logoColor=white)](apps/web/package.json)
[![FastAPI](https://img.shields.io/badge/FastAPI-009688?logo=fastapi&logoColor=white)](apps/api)
[![Next.js](https://img.shields.io/badge/Next.js-14-000000?logo=nextdotjs&logoColor=white)](apps/web)
[![Built with LangChain v1 + LangGraph v1](https://img.shields.io/badge/Built%20with-LangChain%20v1%20%2B%20LangGraph%20v1-1C3C3C)](https://github.com/langchain-ai/langchain)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
</div>
---
Forge is built directly on the **MIT-licensed LangChain v1 + LangGraph v1** framework - and **never** depends on `langgraph-api` (Elastic 2.0) or LangSmith (commercial). Everything you orchestrate runs on your own infrastructure; nothing is sent to a third-party orchestration service.
- **Fully open source (MIT).** No proprietary core, no usage caps, no vendor lock-in.
- **Zero-infra local dev.** Boots on SQLite + embedded Chroma + an in-process scheduler - no Docker, Postgres, or Redis required to start.
- **Production-ready.** Swap to Postgres + pgvector, Redis, and a real secret store with config only - a hardening guard refuses to boot with insecure defaults.
- **Observable by default.** Every run is a span waterfall with tokens, latency, and cost down to fractions of a cent.
## Table of contents
- [Features](#features)
- [Architecture](#architecture)
- [Quick start](#quick-start-local-zero-infra)
- [Run with Docker](#run-with-docker-production-shaped)
- [Documentation](#documentation)
- [Tech stack](#tech-stack)
- [Contributing](#contributing)
- [License](#license)
## Features
<div align="center">
<video src="https://github.com/user-attachments/assets/798a6872-0a47-455f-81be-4566184e3e9c" controls muted width="85%"></video>
</div>
> **[Watch the demo](docs/media/Forge_demo.mp4)** - the in-product **Forge Assistant** builds and runs a workflow end to end. *(If the player doesn't load inline, click the link to play.)*
### Visual Workflow Builder
Wire an entire app on a **drag-and-drop canvas** (React Flow): drop nodes from the palette - **agents** & deep agents, model calls, classifiers, tools, transforms, retrieval, human input/handoff, routers, loops, parallel fan-out/join, subworkflows, and triggers - and connect them with **typed, validated** edges. A per-node inspector and a live **state schema** keep runs type-safe, while a minimap, undo/redo, and copy/paste keep big graphs manageable. **Save**, **Test**, or open the **Playground** to watch nodes light up as the run streams - then **Publish**.
<p align="center"><img src="docs/media/Workflow.png" alt="Forge visual workflow builder: a React Flow canvas wiring retrieval, router, agent, and end nodes, with a node palette, a state-schema inspector, and a minimap" width="90%"></p>
### Visual Agent Builder
Compose an **Agent** or a **Deep Agent** (planning + subagents for long multi-step tasks) from a model, a system prompt, tools, knowledge, Q&A, and a reorderable **middleware stack** - all from friendly forms, no JSON. A live *"what the model sees"* panel shows the exact compiled prompt and middleware execution order before you ship. Build a **supervisor** visually: drag from a Deep Agent's **subagents** handle to any specialist agent node and it folds in as a callable sub-agent (an org-chart branch on the canvas) - each with its own model, tools, and prompt, dispatched via the `task` tool and shown as named sub-agent spans in the trace.
<p align="center"><img src="docs/media/Agents.png" alt="Forge visual agent builder with model, instructions, tools, and a 'what the model sees' panel" width="90%"></p>
### Tool Builder with Response Projection
Register **REST, GraphQL, Code, SQL, MCP, or built-in** tools and test them live against real inputs. A **JMESPath response projection** trims bulky payloads *before* they reach the model - watch the raw → projected **token meter** shrink in real time to control cost. Every outbound call is screened by an **SSRF guard**, with optional retries, rate limits, and caching. Point a tool's endpoint at a **per-environment value** with `{{env.*}}` (from `FORGE_TOOL_VARS`) so the same tool row resolves to your dev / qa / prod host per deploy. The platform **built-ins** (time, calculator, web fetch/search, knowledge search, memory) are auto-provisioned into every project and protected from deletion. Organize tools into **tool sets** - reusable, many-to-many groups that double as folders on the screen, get granted to an agent in one click, and publish as MCP toolsets.
<p align="center"><img src="docs/media/Tools.png" alt="Forge tool builder with request config, live response, and a raw-to-projected token cost meter" width="90%"></p>
The Tools screen groups everything by set (switchable between grid and list), with one-click **export / import** to move tools between projects:
<p align="center"><img src="docs/media/Tools_dashboard.png" alt="Forge Tools screen: the tool grid grouped into reusable tool sets, with grid/list views and one-click export and import" width="90%"></p>
### Knowledge & RAG
Ground agents in **your own data**. Add pasted text, URLs, crawled sites, or uploaded files (`.txt/.md/.csv/.json/.html/.pdf`); Forge chunks, embeds (**offline-capable by default**), and stores them as vectors organized in folders. Curated **Q&A pairs** deflect common questions, and a **search debugger** lets you inspect exactly what retrieval returns.
<p align="center"><img src="docs/media/Knowledge.png" alt="Forge knowledge screen showing document sources, folders, chunking status, and Q&A" width="90%"></p>
The built-in **search debugger** plots every chunk by semantic similarity and overlays a query - so you can see exactly which chunks a search retrieves, and why:
<p align="center"><img src="docs/media/Knowledge_storage_debugger.png" alt="Forge knowledge search debugger: a PCA chunk map colored by source, with a query overlay marking the retrieved chunks" width="90%"></p>
### Generative UI Components
Let agents render **rich, interactive UI** - tables, cards, forms - instead of plain text. Author an HTML/CSS component once with a live preview; the model emits a tiny payload while the markup renders in a **sandboxed iframe** and never bloats the token stream. Buttons can send structured actions straight back to the agent.
<p align="center"><img src="docs/media/Components.png" alt="Forge component builder with HTML/CSS editor and a live sandboxed preview of a weather card" width="90%"></p>
### Embeddable Web Widget
Drop your assistant onto **any website** with a one-line script - a floating chat bubble locked to the origins you allow. End users see only the conversation; operational details (steps, tokens, cost, node names) stay private in the dashboard.
<p align="center"><img src="docs/media/Embeddings.png" alt="Forge embed screen with widget toggle, allowed origins, and a copy-paste launcher snippet" width="90%"></p>
### Deploy anywhere - one run API, MCP & channels
Ship the same workflow through many surfaces without rewriting it: call it server-to-server over a **single run API** (`POST /run` handles new turns, streaming, and human-in-the-loop resumes), expose it as an **MCP server**, deploy it to **email**, or drop in the **web widget**. Per-request caller context (`X-Forge-Context`) lets tools act on behalf of your end users - with secrets never in the request body.
<p align="center"><img src="docs/media/Integrations.png" alt="Forge Connect screen showing the run API: the Forge API base URL, the POST /run endpoint, and a copy-paste curl example" width="90%"></p>
> [!NOTE]
> **Connectors are not yet fully implemented.** The prebuilt, one-click **connector library / marketplace** (Google, Slack, Notion, GitHub, Salesforce, …) is on the [roadmap](docs/ROADMAP.md#planned--exploring) — it is **not shipped yet**. Today you integrate an external system by hand: create a **REST / GraphQL / MCP tool** and pair it with an **Auth Provider** (Bearer / API key / Basic / OAuth2 / CSRF-session). *(Note: the `connector` **role** — a least-privileged MCP-only user — is a separate, shipped feature and unrelated to the connector library.)*
### Observability & Traces
Every run is captured as a **collapsible span tree** - model calls, tools, chains, sub-agents, latency, tokens, and **cost**, nested by real parent/child so you can see exactly what happened and what it cost. Deep-agent dispatches appear as named `subagent · <name>` spans, and the Playground streams a **live agent-activity** timeline (which sub-agent/tool ran, as it happens). Pair it with **Evaluations** to catch regressions before you publish, and export traces to any **OpenTelemetry** collector (e.g. Langfuse).
<p align="center"><img src="docs/media/Traces.png" alt="Forge traces screen showing a run's span waterfall with per-step latency, tokens, and cost" width="90%"></p>
### Guardrails, budgets & governance
Run it like production. A project-level **Guardrails & Egress** policy (PII redaction, blocked terms, and a network allow/deny list) applies to every agent by default; **budgets & quotas** cap spend and tokens; **versioning** snapshots every change; and it all sits behind per-project **roles / RBAC** and an audit log - from one Settings surface.
<p align="center"><img src="docs/media/Settings.png" alt="Forge project Settings with a section sidebar: General, Members and Roles, API Keys, Model Pricing, Budgets and Quotas, Guardrails and Egress, Versioning, and more" width="90%"></p>
### And many more
- **Channels** - deploy a workflow to **Email**.
- **Triggers** - webhooks, schedules (interval/cron), inbound email, and polling "app events".
- **Human-in-the-loop** - approve/reject pauses and live **handoff** to an Agent inbox, with the reply delivered back over the same channel.
- **Auth Providers** - Bearer, API key, Basic, OAuth2 (client-credentials **and** 3-legged user login with auto-refresh), and CSRF/session - backed by encrypted, reference-only secrets (`secret://…`).
- **MCP, both ways** - expose your tools as an **MCP server** over native Streamable-HTTP/SSE (Claude Desktop, Cursor, and VS Code connect directly - no bridge), authenticated by a project key, per-user **personal access tokens**, or optional **OAuth 2.1**, publishing tool sets, knowledge, Q&A, or a whole workflow; and consume tools from external MCP servers.
- **Guardrails & egress policy** - one project-level I/O policy (PII redaction, blocked terms, and a network allow/deny list) enforced on every agent by default; a project can only *tighten* it, never loosen it.
- **Import & export** - move tools, workflows, agents, and components between projects as portable JSON bundles (secret *values* never leave).
- **Evaluations** - datasets scored by `contains` / `exact` / `regex` / LLM-`judge` for a pass rate per workflow, **streamed live** (each case resolves in the UI as it finishes, with per-case latency + tokens).
- **Long-term memory**, response caching, retries with backoff, and per-tenant **budgets**.
- **Multi-tenant projects & roles** (owner/admin/editor/viewer/connector) with **per-project RBAC**, scoped revocable API keys, entity **version history**, and an **audit log**.
- **Provider-agnostic models** - OpenAI, Anthropic, Google, or any LangChain provider, plus an offline `fake:` model so you can build the plumbing without spending a cent.
> See the full **[User Manual](docs/MANUAL.md)** for an end-to-end tour and worked examples.
## Architecture
A pnpm + Python monorepo with a shared schema contract that keeps the backend and frontend in lockstep:
```
forge/
├── apps/
│ ├── api/ FastAPI backend - the engine (compiler, registry, middleware), tools,
│ │ auth, knowledge, tracing, MCP server, build assistant. Dockerfile +
│ │ Alembic migrations live here. [Python]
│ └── web/ Next.js console - canvas, config panels, playground, traces. Dockerfile
│ lives here. [TS/React]
├── packages/
│ └── schemas/ Shared JSON Schemas - the single source of truth, imported by the
│ backend validator/compiler AND the frontend <SchemaForm>.
├── docs/ User manual, roadmap, and media.
├── infra/ Production database swaps (Postgres row-level-security policies).
└── docker-compose.yml Production-shaped stack (Postgres · Redis · api · worker · web).
```
The **shared schemas** are the contract behind three consumers: the backend **validator** (rejects bad configs on save), the **compiler** (`compile_workflow`, `build_middleware`), and the frontend **`<SchemaForm>`** (forms generated from the same files).
## Quick start (local, zero-infra)
### Prerequisites
- **Python** 3.113.13 - the backend engine
- **Node** 22 LTS and **pnpm** 9+ - the web console
- Nothing else - the local stack runs on SQLite + embedded Chroma + an in-process scheduler, so **no Docker, Postgres, or Redis** is required to start.
### 1. Configure environment
```bash
cp .env.example .env # macOS/Linux
copy .env.example .env # Windows
```
Open `.env` and fill in what you need (e.g. an LLM provider key). Everything is optional to boot; agents that call a model need a provider key. **Never commit your `.env`** - it is already git-ignored.
### 2. Backend (FastAPI engine)
```bash
cd apps/api
python -m venv .venv && source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -e ".[dev,all]" # engine + tests + vectors/providers/knowledge/MCP
pytest # optional: validate the engine (offline)
uvicorn forge.main:app --reload --port 8000 # http://localhost:8000/docs
```
### 3. Frontend (Next.js console)
In a second terminal, from the repo root:
```bash
pnpm install
pnpm --filter web dev # http://localhost:3000
```
Open **http://localhost:3000** for the console and **http://localhost:8000/docs** for the API. On first run, sign in with `you@forge.local` / `forge-admin`, or create a fresh workspace.
## Run with Docker (production-shaped)
The included [`docker-compose.yml`](docker-compose.yml) brings up a production-shaped stack - **Postgres** (app DB + durable checkpointer), **Redis** (shared rate-limit/idempotency + worker queue), the **API**, a **worker**, and the **web** console:
```bash
# Set real secrets first (FORGE_JWT_SECRET, FORGE_BOOTSTRAP_ADMIN_PASSWORD, provider keys)
docker compose up --build
```
With `FORGE_ENVIRONMENT=production`, Forge enables a hardening guard and **refuses to boot** with default secrets, SQLite, or a non-durable checkpointer. See [`apps/api/README.md`](apps/api/README.md) and **[Manual §13 - Going to production](docs/MANUAL.md)** for the full, annotated configuration.
## Documentation
| Doc | What's inside |
|---|---|
| **[User Manual](docs/MANUAL.md)** | Full feature tour, the node catalog, and end-to-end use cases (no developer knowledge needed). |
| **[Backend README](apps/api/README.md)** | API layout, local-vs-production swaps, and dependency notes. |
| **[Tech stack & architecture](TECH_STACK.md)** | Every dependency and why it's there, plus request/run sequence diagrams. |
| **[Roadmap & status](docs/ROADMAP.md)** | What's shipped, what's in progress, and what's planned next (connectors, more channels, and more). |
| **[Changelog](CHANGELOG.md)** | Notable changes, following Keep a Changelog + SemVer. |
| **[Contributing](CONTRIBUTING.md)** | Local setup, the checks CI runs, and commit/PR conventions. |
## Tech stack
| Layer | Technology |
|---|---|
| **Engine** | LangChain v1 · LangGraph v1 · Deep Agents - MIT framework only |
| **Backend** | Python · FastAPI · SQLAlchemy 2 (async) · Pydantic v2 |
| **Frontend** | Next.js 14 (App Router) · React 18 · TypeScript · React Flow |
| **Data (local)** | SQLite · embedded Chroma · in-process cache/scheduler |
| **Data (prod)** | Postgres 16 + pgvector · Redis 7 · Fernet/Vault secrets |
| **Observability** | Built-in tracer + cost accounting · OpenTelemetry / Langfuse export |
## Contributing
Contributions are welcome. Forge is MIT-licensed and built to be extended.
1. Fork the repo and create a feature branch.
2. Backend changes: run `pytest` and `ruff check forge migrations` from `apps/api`.
3. Keep the **shared schemas** (`packages/schemas`) authoritative - the validator, compiler, and frontend forms all read from them.
4. Open a pull request describing the change and the reasoning.
Found a bug or have an idea? Please [open an issue](https://github.com/nihalashetty/Forge/issues).
## License
Forge is released under the **[MIT License](LICENSE)** - free to use, modify, and distribute, including commercially. It builds only on the MIT-licensed LangChain/LangGraph ecosystem, with no Elastic-2.0 or commercial-license dependencies.
<div align="center">
<sub>Built on the open-source LangChain and LangGraph ecosystem.</sub>
</div>
+57
View File
@@ -0,0 +1,57 @@
# Security Policy
Forge is self-hosted and handles sensitive material — provider API keys, encrypted
secrets, auth credentials, and outbound requests to your systems. We take security issues
seriously.
## Supported versions
Forge is pre-1.0 and under active development. Security fixes land on the `main` branch and
are included in the next release. Please test against the latest `main` before reporting.
| Version | Supported |
|---|---|
| `main` (latest) | ✅ |
| older tags | ❌ (please upgrade) |
## Reporting a vulnerability
**Please do not open a public issue for security vulnerabilities.**
Report privately via **[GitHub Security Advisories](https://github.com/nihalashetty/Forge/security/advisories/new)**
(Repository → *Security**Report a vulnerability*). This keeps the report confidential
until a fix is available.
When reporting, include:
- A description of the issue and its impact.
- Steps to reproduce (a minimal proof-of-concept helps).
- Affected component/version (commit SHA if possible).
**Do not include real secrets, tokens, or customer data** in your report — redact or use
placeholders.
We aim to acknowledge reports promptly, keep you updated on remediation, and credit
reporters (unless you prefer to remain anonymous) once a fix ships.
## Built-in safeguards
Forge ships several defense-in-depth controls; when reporting, note if your finding
bypasses one:
- **SSRF guard** on every outbound call (tools, webhooks, fetch, crawl) — private,
loopback, and cloud-metadata addresses are blocked by default.
- **Encrypted, reference-only secrets** (`secret://…`) via a Fernet master key; secret
values are write-only and never returned.
- **Production hardening guard** that refuses to boot with default secrets, auth disabled,
a non-durable checkpointer, or the SSRF guard off.
- **Multi-tenant isolation** with query-level scoping and optional Postgres row-level
security.
- **Sandboxed code tools** (RestrictedPython) and origin-locked embed widgets.
## Scope
In scope: the Forge API, web console, engine, tools/auth subsystems, and the container
stack. Out of scope: vulnerabilities in third-party dependencies (report those upstream;
tell us if Forge's usage makes them exploitable), and issues requiring a
already-compromised host or admin credentials.
+211
View File
@@ -0,0 +1,211 @@
# Forge — Technology Stack
Every technology used in Forge, its purpose, and where it lives. Sourced from `apps/web/package.json`, `apps/api/pyproject.toml`, both `Dockerfile`s, `docker-compose.yml`, and `apps/api/forge/config.py`. Backend deps grouped `[in brackets]` are **optional extras** (installed on demand / prod); everything else is core.
| Layer | Technology | Use case | Where it lies |
|---|---|---|---|
| **Frontend** | Next.js 14.2 | React framework; standalone server + build-time API proxy rewrites to the backend | `apps/web` (web console); `next.config.mjs` |
| **Frontend** | React 18.3 + React DOM | UI component rendering | `apps/web` |
| **Frontend** | TypeScript 5.6 | Typed frontend language | `apps/web` (`tsconfig.json`) |
| **Frontend** | @xyflow/react (React Flow) 12 | Visual drag-and-drop node-graph editor — the workflow builder canvas | `apps/web` (builder/canvas components) |
| **Frontend** | react-markdown 9 + remark-gfm 4 | Render agent/chat responses as GitHub-flavored Markdown | `apps/web` (chat UI) |
| **Frontend** | mustache 4 | Client-side `{{...}}` template rendering | `apps/web` |
| **Frontend** | jmespath 0.16 | JSON projection/query in the browser | `apps/web` |
| **Frontend / Build** | Node.js 22 | JS runtime for building and serving the console | `apps/web/Dockerfile` (`node:22-alpine`) |
| **Build / Monorepo** | pnpm (workspace) | Package manager + monorepo workspaces (`apps/web`, `packages/*`) | repo root (`pnpm-workspace.yaml`, `corepack`) |
| **Backend / API** | Python 3.113.13 | Backend language (runtime image: `python:3.12-slim`) | `apps/api` |
| **Backend / API** | FastAPI 0.115 | HTTP/REST API framework, routing, dependency injection, middleware | `apps/api/forge/main.py`, `forge/routers/*` |
| **Backend / API** | Uvicorn[standard] 0.32 | ASGI server that runs the app | serve command (`uvicorn forge.main:app`) |
| **Backend / API** | sse-starlette 2.1 | Server-Sent Events streaming of run event frames (`run`/`node_start`/`messages`/`done`) | `forge/routers/runs.py` |
| **Backend / API** | python-multipart | Multipart form / file upload parsing | `apps/api` |
| **Backend / API** | hatchling | Python package build backend | `apps/api/pyproject.toml` |
| **Config / Data** | Pydantic 2.9 | Request/response DTOs, data validation | `forge/schemas/dto.py`, models |
| **Config / Data** | pydantic-settings 2.6 | Environment-driven application settings | `forge/config.py` |
| **Config / Data** | email-validator 2.2 | Validate email fields (invites, auth) | `apps/api` |
| **Agent / Engine** | LangChain 1.3 + langchain-core | LLM orchestration primitives (messages, tools, model bindings) | `forge/engine/*` |
| **Agent / Engine** | LangGraph 1.2 | Stateful agent/workflow graph engine — compiles nodes into a runnable graph; the execution core | `forge/engine/compiler.py`, `forge/services/runs.py` |
| **Agent / Engine** | langgraph-checkpoint 4 | Checkpointer interface for durable / resumable run + HITL state | `forge/engine`, `forge/services/runs.py` |
| **Agent / Engine** | langgraph-checkpoint-sqlite 3 | SQLite-backed checkpointer (dev default) | `.data/checkpoints.sqlite` |
| **Agent / Engine** | deepagents 0.6 | Deep Agents harness (planning, subagents, virtual filesystem, sandbox) — always-registered `deep_agent` node | agent node palette |
| **Model Providers** | langchain-openai 1.x | OpenAI model access | `[providers]` extra |
| **Model Providers** | langchain-anthropic 1.x | Anthropic Claude models + prompt-caching middleware | `[providers]` extra; `default_anthropic_prompt_caching` |
| **Model Providers** | langchain-google-genai 4.2+ | Google Gemini models | `[providers]` extra |
| **Model Providers** | tiktoken 0.7 | Accurate token counting for the cost meter / budgets (falls back to len/4) | `forge/tracing/pricing.py` |
| **Tooling Primitives** | httpx 0.27 | Outbound HTTP for REST/GraphQL tools, webhooks, `web_fetch`, OAuth/token fetches | tool runtime + egress/SSRF guard |
| **Tooling Primitives** | jsonschema 4.23 | Validate node/tool config against the shared JSON Schemas | `packages/schemas` + engine |
| **Tooling Primitives** | jmespath 1.0 (py) | JSON projection of tool outputs | tool runtime |
| **Tooling Primitives** | RestrictedPython 7.4 | AST-sandboxed execution of code tools (opt-in; hardening layer, not OS isolation) | code tool runtime (`enable_code_tools`) |
| **MCP** | langchain-mcp-adapters 0.2 | Consume external MCP servers as tools | `[mcp]` extra |
| **MCP** | mcp 1.9 | Model Context Protocol SDK | `[mcp]` extra |
| **MCP** | fastmcp 3 | Expose Forge projects as MCP servers | `[mcp]` extra |
| **Knowledge / RAG** | chromadb 1.5 | Embedded persistent vector store (zero infra) | `[vectors]` extra; `.data/chroma` |
| **Knowledge / RAG** | fastembed 0.3+ | **Default embedder** — local open-source ONNX model (no API cost / no PyTorch); also powers the local cross-encoder **reranker** (`TextCrossEncoder`) | `[knowledge]` extra |
| **Knowledge / RAG** | langchain-text-splitters 1.x | Chunk/split documents for ingestion | `[knowledge]` extra |
| **Knowledge / RAG** | pypdf 5 | Extract text from PDF documents | `[knowledge]` extra |
| **Knowledge / RAG** | beautifulsoup4 4.12 | Parse HTML for URL ingestion | `[knowledge]` extra |
| **Knowledge / RAG** | rank-bm25 0.2 | Lexical (BM25) ranking for hybrid vector + keyword search | `[knowledge]` extra |
| **Persistence** | SQLAlchemy 2.0 [asyncio] | Async ORM / database access layer | `forge/models/entities.py` |
| **Persistence** | aiosqlite 0.20 | Async SQLite driver (dev default) | dev DB `.data/forge.db` |
| **Persistence** | Alembic 1.14 | Schema migrations (controlled prod path) | `apps/api/migrations`, `alembic.ini` |
| **Persistence** | greenlet 3.1 | Async/sync bridge required by SQLAlchemy asyncio | runtime dependency |
| **Persistence** | SQLite | Default dev database + checkpointer store | `.data/*.db` (dev only) |
| **Persistence** | PostgreSQL 16 | Production application database + shared durable checkpointer | `docker-compose.yml` (prod) |
| **Persistence** | asyncpg 0.30 / psycopg[binary,pool] 3.2 | Async Postgres drivers | `[postgres]` extra (prod) |
| **Persistence** | langgraph-checkpoint-postgres 2.x | Durable Postgres checkpointer shared across workers (prod/HITL) | `[postgres]` extra (prod) |
| **Secrets / Auth** | cryptography 43 (Fernet) | Encrypt stored secrets/credentials with a master key | `.data/master.key`; secrets service |
| **Secrets / Auth** | python-jose[cryptography] 3.3 | Mint/verify platform JWT access + refresh tokens (with `kid` rotation) | auth layer (`forge/config.py` JWT settings) |
| **Secrets / Auth** | bcrypt 4 | Password hashing for local accounts | auth layer |
| **Background / Workers** | Redis 7 | Shared rate-limit / idempotency store + worker queue backend | `docker-compose.yml` (prod); `[workers]` extra |
| **Background / Workers** | arq 0.26 | Async task queue + worker for offloaded run execution | `forge/worker.py`, `forge/queue.py`; `[workers]` |
| **Background / Workers** | croniter 26 | Evaluate cron `schedule` triggers | scheduler; `[workers]` |
| **Observability** | opentelemetry-sdk 1.20 | Emit run traces/spans (GenAI semantic conventions) | `forge/tracing/otel.py`; `[observability]` |
| **Observability** | opentelemetry-exporter-otlp-proto-http 1.20 | Export spans to an OTLP collector / Langfuse | `forge/tracing/otel.py`; `[observability]` (opt-in via `otel_enabled`) |
| **Infra / Deploy** | Docker + Docker Compose | Production-shaped container stack (postgres + redis + api + worker + web) | repo root (`docker-compose.yml`, `apps/*/Dockerfile`) |
| **Dev Tooling** | pytest 8.3 + pytest-asyncio 0.24 | Backend test suite (async mode auto) | `apps/api/tests`; `[dev]` extra |
| **Dev Tooling** | anyio 4.6 | Async test/runtime utilities | `[dev]` extra |
| **Dev Tooling** | ruff 0.15 (pinned `>=0.15,<0.16`) | Linting + import sorting/formatting; range-pinned so CI lint is reproducible | `pyproject.toml [tool.ruff]`; `[dev]` extra |
| **Dev Tooling** | mypy 1.13+ | Static type-checking (advisory in CI; gradual adoption) | `pyproject.toml [tool.mypy]`; `[dev]` extra |
| **Dev Tooling** | Vitest 2 + Testing Library | Frontend unit/component tests (`pnpm --filter web test`) | `apps/web` (`devDependencies`) |
**Notes**
- **Local dev needs no external infra**: SQLite + embedded Chroma + in-process (fake) cache/queue. The prod swaps — Postgres, Redis, OTLP, Vault/KMS — are configuration-only (no code changes).
- **Framework is MIT-only**: LangChain/LangGraph OSS packages; deliberately **not** `langgraph-api` or LangSmith.
- **Optional extras** map to `pip install -e ".[...]"` groups in `pyproject.toml`: `providers`, `vectors`, `knowledge`, `mcp`, `workers`, `postgres`, `observability`, `dev` (and `all` = vectors+providers+knowledge+mcp).
---
## Architecture
How the pieces above fit together. The browser talks to a **same-origin** Next.js proxy (`/api/forge/*`) that rewrites to the FastAPI backend; every run — whatever triggers it — funnels through one `RunService`, and one `ForgeTracer` observes it.
```mermaid
flowchart TB
subgraph Client["Client tier"]
Browser["Browser — Next.js console<br/>(builder canvas · chat UI)"]
Embed["Embed widget<br/>(anonymous · publishable key)"]
end
subgraph Edge["Edge / web server"]
Next["Next.js 14 server<br/>same-origin proxy: /api/forge/* to API"]
end
subgraph Triggers["Trigger sources"]
UI["Interactive run (SSE)"]
WH["Webhook"]
SCH["Schedule (cron)"]
CH["Email channel"]
MCPin["MCP server surface"]
end
subgraph API["FastAPI backend — apps/api/forge"]
MW["Middleware<br/>Audit · TrustedHost · CORS · rate-limit<br/>auth: JWT / service token · X-Forge-Context"]
Routers["Routers (/v1/...)"]
Dispatch["Dispatch service"]
RunSvc["RunService<br/>create_run · stream · run_to_completion · resume"]
Compile["Engine: build_compile_context to compile_workflow"]
Graph["LangGraph StateGraph — graph.astream()"]
Tracer["ForgeTracer callback<br/>spans: tokens · cost · latency"]
end
subgraph Nodes["Graph nodes & tools"]
Models["Model providers<br/>OpenAI · Anthropic · Google GenAI"]
Tools["Tools: httpx REST/GraphQL · MCP client<br/>code (RestrictedPython) · knowledge/RAG"]
Deep["deep_agent (Deep Agents)"]
end
subgraph DataTier["Persistence & state"]
DB["SQLAlchemy to SQLite (dev) / Postgres (prod)<br/>Run · Trace · Span · Thread · AuditLog"]
CP["LangGraph checkpointer<br/>SQLite / Postgres"]
Vec["Chroma vector store (.data/chroma)"]
Secrets["Fernet master.key (.data)"]
end
subgraph Async["Async / prod tier"]
Redis["Redis — rate-limit · idempotency · queue"]
Worker["arq worker (offloaded runs)"]
OTel["OpenTelemetry to OTLP / Langfuse"]
end
Browser --> Next
Embed --> Next
Next --> MW
UI --> MW
WH --> Dispatch
SCH --> Dispatch
CH --> Dispatch
MCPin --> Dispatch
MW --> Routers --> RunSvc
Dispatch --> RunSvc
RunSvc --> Compile --> Graph
Graph -. callbacks .-> Tracer
Graph --> Models
Graph --> Tools
Graph --> Deep
Tools --> Vec
Tools -. encrypt/decrypt .-> Secrets
RunSvc --> DB
Graph --> CP
Tracer --> DB
Tracer --> OTel
RunSvc <--> Redis
Dispatch --> Worker
Worker --> RunSvc
Routers -. SSE frames .-> Next
Next -. SSE .-> Browser
```
---
## Example flow — a user sends a chat message
The interactive path (console or embed widget). Sending a message is **two HTTP calls**: a `POST` that creates the run row, then a `GET` that opens the SSE stream carrying tokens and lifecycle events back to the browser.
```mermaid
sequenceDiagram
autonumber
actor U as User
participant B as Browser (chat UI)
participant N as Next.js proxy
participant M as FastAPI middleware
participant R as Runs router
participant S as RunService
participant G as LangGraph astream
participant T as ForgeTracer
participant P as Model provider
participant D as DB / checkpointer
U->>B: type message, hit send
B->>N: POST /v1/projects/{p}/workflows/{w}/runs
N->>M: same-origin proxy to api:8000
M->>R: authenticated request (audit · auth · rate-limit)
R->>R: idempotency · run rate limit · identity · daily quota
R->>S: create_run(input, thread_id)
S->>D: INSERT Run (queued) + Thread
S-->>R: run
R-->>B: 201 { run_id, thread_id }
B->>N: GET /v1/.../runs/{run_id}/stream (EventSource / SSE)
N->>R: proxy stream request
R->>S: stream(run_id)
S->>D: Run to running
S->>G: build context to compile_workflow, astream(callbacks=[Tracer])
activate G
loop each node / token
G->>T: on_llm_start / on_tool_start (open span)
G->>P: LLM call
P-->>G: tokens
G-->>B: SSE: node_start · messages (tokens) · custom
G->>T: on_llm_end (tokens · cost · latency)
end
deactivate G
alt HITL interrupt
S-->>B: SSE: interrupt (awaiting human input)
Note over B,S: user approves to resume the same run
else completed
S->>D: _write_trace to Trace + Span rows (+ OTel export)
S-->>B: SSE: done { answer, total_tokens, total_cost_usd }
end
B->>U: render streamed answer
```
> **Non-interactive triggers** (webhook / schedule / email) skip the browser and the SSE stream: they enter through the **Dispatch service** and call `RunService.run_to_completion()` instead of `stream()` — but the compile → LangGraph → ForgeTracer → Trace/Span path is identical, which is what keeps observability consistent across every entry point.
+71
View File
@@ -0,0 +1,71 @@
# Forge API - FastAPI on the MIT LangChain/LangGraph stack.
FROM python:3.12-slim AS base
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
# The default local (fastembed) embedder's model is baked into the image at this path
# (see the pre-download step below); the app reads the same dir at runtime, so ingestion
# works fully offline with no first-run download. Outside the /app/.data volume, so it's
# part of the image, not the persisted volume.
FORGE_FASTEMBED_CACHE_DIR=/app/.fastembed-cache
# Build deps for psycopg/cryptography wheels (kept minimal; most ship manylinux wheels).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Build context is the REPO ROOT (see docker-compose.yml: context: . / dockerfile:
# apps/api/Dockerfile) so we can COPY packages/schemas, which lives above apps/api.
#
# Third-party deps install in a layer keyed only on pyproject.toml + a package stub, so a
# pure forge/ source edit does NOT invalidate this (expensive) layer. The editable install
# just links /app/forge onto sys.path, so overwriting the stub with the real tree afterwards
# needs no reinstall. The BuildKit pip cache mount reuses already-downloaded wheels instead
# of re-hitting PyPI even when the layer does re-run (e.g. a pyproject.toml change), so a
# source edit can never turn into a PyPI round-trip / offline build failure.
COPY apps/api/pyproject.toml apps/api/README.md ./
RUN mkdir -p forge && touch forge/__init__.py
# Production extras: model providers, vectors, knowledge, MCP, workers (Redis/arq), Postgres.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && \
pip install -e ".[providers,vectors,knowledge,mcp,workers,postgres]"
# Real source over the stub (this layer changes on every edit, but it is AFTER the install).
COPY apps/api/forge ./forge
COPY apps/api/alembic.ini ./alembic.ini
COPY apps/api/migrations ./migrations
# Shared JSON Schemas live at the repo root, outside the old ./apps/api context. Bake them at
# /app/packages/schemas so config.py's schemas_dir default (/app/packages/schemas) resolves.
COPY packages/schemas ./packages/schemas
# Pre-download the default local embedder (fastembed) model into the image so the container
# ships self-contained: no ~130MB HuggingFace fetch on the first ingest, and no runtime egress
# to huggingface.co (which a locked-down deploy may block). Keep this model id in sync with
# forge.knowledge.embeddings._DEFAULT_FASTEMBED. Runs before the chown so the forge user owns it.
RUN python -c "import os; from fastembed import TextEmbedding; TextEmbedding(model_name='BAAI/bge-small-en-v1.5', cache_dir=os.environ['FORGE_FASTEMBED_CACHE_DIR'])"
# Run as a non-root user. Pre-create /app/.data (holds the Fernet master.key + Chroma store)
# owned by forge: an empty named volume mounted there copies the image mountpoint's ownership on
# first use, so this keeps the volume forge-writable. Without it the volume would mount root-owned
# and the non-root process could not write the master key (PermissionError at startup).
RUN useradd --create-home --uid 10001 forge \
&& mkdir -p /app/.data \
&& chown -R forge:forge /app
USER forge
EXPOSE 8000
# Container healthcheck hits the readiness probe (DB + checkpointer).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/readyz || exit 1
# Apply migrations, then serve. (Schema also self-bootstraps via create_all, but on managed
# Postgres `alembic upgrade head` is the controlled path.)
# No uvicorn --proxy-headers/--forwarded-allow-ips: the app derives the client IP itself and
# trusts X-Forwarded-For only from FORGE_TRUSTED_PROXIES (default none -> the real socket peer).
# Letting uvicorn rewrite the peer from XFF for ANY client (as --forwarded-allow-ips='*' did)
# lets clients spoof their IP to evade per-IP rate limits / poison audit logs. Behind a real
# proxy, set FORGE_TRUSTED_PROXIES to the proxy IP(s).
CMD ["sh", "-c", "alembic upgrade head && uvicorn forge.main:app --host 0.0.0.0 --port 8000"]
+61
View File
@@ -0,0 +1,61 @@
# Forge API
FastAPI backend for **Forge** - the self-hosted agent platform. Built directly on the
MIT-licensed LangChain v1 + LangGraph v1 framework. **Never** depends on `langgraph-api`
(Elastic 2.0) or LangSmith (commercial).
## Local dev (zero external infra)
The default local stack needs **no Docker, Postgres, or Redis**:
| Concern | Local default | Production swap (config-only) |
|---|---|---|
| Relational DB | SQLite (`aiosqlite`) | Postgres 16 (`asyncpg` / `psycopg`) |
| Run durability | `langgraph-checkpoint-sqlite` | `langgraph-checkpoint-postgres` |
| Vectors | Chroma (embedded, persistent) | `pgvector` in the same Postgres - set `vector_backend=pgvector` |
| Cache / queue | in-process | Redis 7 + arq |
| Secrets | Fernet (local key file) | Vault / cloud KMS |
```bash
cd apps/api
python -m venv .venv
.venv\Scripts\activate # Windows (source .venv/bin/activate on *nix)
pip install -e ".[dev]" # core + test deps only
pip install -e ".[dev,all]" # full local stack (vectors + providers + knowledge + MCP)
cp ../../.env.example .env
uvicorn forge.main:app --reload --port 8000
pytest # validate the engine
```
## Layout
```
apps/api/
forge/
main.py FastAPI app factory + lifespan (DB init, checkpointer, scheduler/reaper)
config.py Settings (pydantic-settings, env-driven) + production hardening guard
deps.py FastAPI dependencies: session, auth/tenant resolution, RBAC
security.py Auth primitives: bcrypt password hashing + JWT mint/verify
audit_middleware.py ASGI middleware that audits successful mutations
queue.py, worker.py Optional arq/Redis queue + worker for offloaded runs (prod)
db/ async engine, session, tenant scoping, dev seed/bootstrap
models/ SQLAlchemy ORM (tenants, projects, workflows, runs, traces, ...)
schemas/ Pydantic request/response DTOs + shared JSON-Schema loader/validator
services/ business logic (ProjectSvc, WorkflowSvc, RunSvc, assistant,
portability import/export, tool_sets, ...)
routers/ HTTP + SSE endpoints (incl. assistant, runs, mcp_server, mcp_oauth,
mcp_tokens, tool_sets, connections, models, versions, embed)
engine/ the heart: registry, compiler, state, middleware_compiler, context
nodes/ node-type factories (start, end, agent, llm, tool_call, flow, rag, triggers)
tools/ tool materialization (rest, graphql, code, sql, mcp, builtin) + projection
auth_providers/ Auth Provider resolver (csrf_session, oauth2, bearer, ...)
secrets/ Fernet-encrypted, reference-only secret store
channels/ email deployment surface
knowledge/ EmbeddingStore (Chroma), ingestion/crawl, splitter, hybrid + rerank
tracing/ ForgeTracer callback + span sink + pricing + tool-I/O capture
util/ cross-cutting helpers (SSRF guard, http client, rate limit, mailer, ...)
assistant_skills/ skill(s) the in-product build assistant loads
migrations/ Alembic migrations (prod schema path; SQLite auto-creates in dev)
tests/ pytest suite (engine, tools, knowledge, auth, security, ...)
```
+40
View File
@@ -0,0 +1,40 @@
# Alembic config for Forge. The DB URL comes from FORGE_DATABASE_URL via env.py
# (this file's sqlalchemy.url is a placeholder and is overridden there).
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = sqlite:///./.data/forge.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+3
View File
@@ -0,0 +1,3 @@
"""Forge API - self-hosted LangChain/LangGraph agent platform."""
__version__ = "0.1.0"
@@ -0,0 +1,139 @@
---
name: forge-platform
description: Use when designing, building, debugging, or explaining Forge workflows, nodes, state, routing, middleware, tools, or knowledge - especially custom/complex shapes beyond the canned builders, AND for anything about deep agents, sub-agents, or supervisor patterns (how sub-agents are wired on the canvas), or per-environment tool endpoints ({{env.*}}).
---
# Forge platform deep guide
You are embedded in Forge, a visual builder for LangChain/LangGraph agents. Workflows are
JSON definitions compiled to LangGraph StateGraphs. This guide covers the rules the canned
builder tools don't teach. For the live catalog always call `list_node_types`,
`get_node_schema(type)`, and `list_middleware_types` - they read the real registry.
## Workflow definition shape
```json
{
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
"intent": {"type": "str", "reducer": "last"}},
"entry_node": "start",
"nodes": [{"id": "start", "type": "start", "config": {}, "position": {"x": 40, "y": 200}}],
"edges": [{"source": "start", "target": "..."}]
}
```
Rules:
- Exactly one `start` node; at least one `end` node; every node reachable; a path must reach end.
- Every state key a node WRITES must be declared in `state` (LangGraph rejects undeclared
writes). `create_custom_workflow` auto-declares keys for known node configs, but declare
custom `output_key`s yourself. Types: str, int, float, bool, json, list[str], list[json],
list[message]. Reducers: last (overwrite), add (append lists), merge (dict merge),
add_messages (chat history).
- Messages flow on the `messages` channel; nodes append, never replace.
## Routing patterns
- Triage first (almost every support/chatbot graph): right after `start`, classify the
message into at least `general` vs `support`, then `router` it. Send `general` (greetings,
smalltalk, "what can you do?", capability/meta questions) to a small friendly agent that
answers directly and goes to `end`; route only `support` into the retrieval/ticket
pipeline. Without this, greetings and meta questions fall through retrieval, miss,
and dead-end at a "no relevant data → create a ticket" path - a bad first impression.
Shape: start → classify(general|support) → router → {general: greeter_agent → end,
support: retrieval → … → end}. (Simpler alt: one front agent with a knowledge_search tool
that both chats and answers.)
- Single intent: `classifier` (labels, output_key=intent) → `router`
(expression=intent, cases {label: node_id}, default=fallback_node). Case KEYS are the
exact VALUES the expression takes, not display labels.
- ALWAYS give routers a `default` - with no default, an unmatched value silently ends the
run with no answer.
- MULTI-INTENT (a question with several asks): classifier `multi_label: true` writes a
LIST to state (declare it `list[str]`); router `multi: true` routes to EVERY matching
case in parallel. All branches then converge on ONE synthesizer agent node before end -
its prompt: "compose the partial answers above into one coherent reply". Without a
synthesizer the user sees only the last branch's answer.
- Simpler multi-intent alternative (preferred for support bots): ONE agent with
`config.knowledge` enabled (rag and/or qa) plus any REST tools. The agent searches the KB
once per sub-question and composes one answer itself. Fewer nodes, no fan-out needed.
- Conditional on retrieval success: retrieval `route_key` writes "yes"/"no";
human decisions: human_input `output_key` writes the decision string.
## Knowledge
- Sources (documents) live in folders (free-form names; "" = unfiled). retrieval node
`folders: ["Manuals"]` and the `knowledge_search` tool's `folder` arg filter by folder.
- Q&A pairs have a free-form `kind` (faq, error_workaround, or custom kinds the user
creates) + tags. The retrieval node (include_qa) and agent Q&A filter by `kinds`; empty = all.
- Three ways to ground an agent, pick by how much control you need:
1. `retrieval` NODE = fixed pre-step grounding (one search over BOTH docs + Q&A per run,
before the agent; structurally guaranteed). Use when grounding MUST happen.
2. Agent `config.knowledge` (PREFERRED for conversational/multi-part agents) = built-in,
agent-driven KB access, no separate Tool needed:
```json
"knowledge": {
"rag": {"enabled": true, "folders": ["Manuals"], "top_k": 4},
"qa": {"enabled": true, "kinds": ["faq"]}
}
```
Compiles to `search_knowledge_base` (documents) and/or `lookup_faq` (curated Q&A),
each toggled and scoped (folders / kinds) independently. The agent searches per
sub-question in its own phrasing - so ONE agent answers multi-part questions.
3. `knowledge_search` builtin TOOL = same idea but as a standalone Tool row (use when you
want to share one tool across agents, or filter folder per-call). For a single agent,
`config.knowledge` is simpler.
## Human-in-the-loop (real interrupts only)
- `human_input` node pauses the run (LangGraph interrupt) until a human decides in the
Playground. `output_key` exposes the decision to a router.
- HumanInTheLoopMiddleware (`approve_tools` on builders / `human_in_the_loop` middleware)
pauses before specific TOOL calls.
- NEVER simulate approval via prompt text. Verify with test_workflow that nodes_visited
ends in `__interrupt__`.
## Middleware (agent nodes)
Per-agent `middleware: [{type, config, enabled}]`. Useful types: summarization,
model_fallback, model_retry, tool_retry (retry_on: timeout/connection/http_error/...),
pii, guardrail_regex (block actually replaces the reply), model_call_limit,
tool_call_limit, tenant_budget, llm_tool_selector, context_editing, tool_emulator,
dynamic_model_by_state, tool_filter_by_context, human_in_the_loop. Call
`list_middleware_types` for configs.
## Deep agents & sub-agents (supervisor pattern)
- A `deep_agent` node is a supervisor: `create_agent` + only the deepagents middleware its config
toggles on. `planning: true` adds a write_todos planner; `filesystem.enabled` adds a virtual FS;
`skills` adds skill files. All are OFF by default (each is pure token overhead until needed) - a
plain `agent` is cheaper for a simple lookup; reach for `deep_agent` for open-ended, multi-step
work you want to DELEGATE.
- **Sub-agents are wired on the canvas**, not inline JSON: an edge with `source_handle: "subagents"`
from the deep_agent to a specialist `agent` (or `deep_agent`) node folds that node in as a callable
sub-agent. The compiler lifts each wired node's config into the supervisor's `subagents`
(name/description/system_prompt/tools/model) and drops it as a standalone graph node. The
supervisor calls them via the `task` tool; results come back as named `subagent · <name>` spans.
- Give every specialist a clear **`config.description`** - the supervisor reads it (like a tool
description) to decide when to dispatch it. Each sub-agent keeps its OWN model + tools; if unset it
inherits the supervisor's model.
- Folded sub-agent nodes legitimately have NO outgoing flow edge (they run inside the supervisor, not
as graph steps) - validation exempts them from the dead-end warning. The supervisor still needs its
own `→ end` edge. Sub-agents can't see the chat history, so the supervisor must pass any needed
identifiers in the `task` description.
- Prefer a canvas supervisor over one mega-prompt when the work splits into distinct specialties
(e.g. lookup / create / update / validate); it keeps each sub-agent's tools + prompt focused.
## Per-environment tool endpoints
- A REST `url_template` / GraphQL `endpoint` (or an auth template) may reference `{{env.<key>}}`,
resolved from the deployment's `FORGE_TOOL_VARS` map - so one tool row targets dev/qa/prod hosts
per deploy. A referenced key that isn't configured FAILS the call loudly (never a broken URL).
`{{ctx.*}}` (per-run injected values) stays lenient. Use `{{env.*}}` for the base host, `{{ctx.*}}`
for per-user/per-run values (tokens, ids).
## Build discipline
1. write_todos the plan. 2. list_resources (reuse, never duplicate names).
3. Build (canned builder if it fits, else create_custom_workflow). 4. test_workflow with
a realistic question, a greeting, an off-topic question - and every branch/intent.
5. evaluate_build to judge the results against the user's actual request. 6. Fix and
re-test until the judge passes. Only then report success.
+82
View File
@@ -0,0 +1,82 @@
"""Centralized audit middleware.
Records every successful mutating request (POST/PUT/PATCH/DELETE) as an AuditLog row -
so create/update/delete of any resource is audited without each router opting in. Pure
ASGI (peeks at the response-start status only) so it never buffers a body and can't break
the SSE run/assistant streams. The actor is taken from the JWT when present, else the
seeded dev tenant; auth endpoints are skipped (already audited in their router).
"""
from __future__ import annotations
from forge.config import settings
from forge.security import TokenError, decode_token
from forge.services.audit import AuditService
from forge.util.clientip import resolve_client_ip
_SKIP_PREFIXES = ("/v1/auth", "/v1/audit")
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
def _actor_from_headers(headers: dict[bytes, bytes]) -> tuple[str | None, str | None]:
auth = headers.get(b"authorization", b"").decode("latin-1")
if auth[:7].lower() == "bearer ":
try:
claims = decode_token(auth[7:].strip(), expected_type="access")
return claims.get("sub"), claims.get("tid")
except TokenError:
return None, None
return None, None
def _client_ip(scope, headers: dict[bytes, bytes]) -> str | None:
# Believe X-Forwarded-For only from a configured reverse proxy (settings.trusted_proxies) -
# the SAME rule as deps.client_ip. Previously this trusted XFF unconditionally, so any
# client could poison the audit IP.
client = scope.get("client")
peer = client[0] if client else None
fwd = headers.get(b"x-forwarded-for")
return resolve_client_ip(peer, fwd.decode("latin-1") if fwd else None, settings.trusted_proxies)
class AuditMiddleware:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope.get("method") not in _MUTATING:
return await self.app(scope, receive, send)
path = scope.get("path", "")
if any(path.startswith(p) for p in _SKIP_PREFIXES):
return await self.app(scope, receive, send)
status_code = {"v": 0}
async def send_wrapper(message):
if message["type"] == "http.response.start":
status_code["v"] = message["status"]
await send(message)
await self.app(scope, receive, send_wrapper)
if not (200 <= status_code["v"] < 400):
return
headers = dict(scope.get("headers") or [])
actor_id, tenant_id = _actor_from_headers(headers)
if tenant_id is None:
app = scope.get("app")
tenant_id = getattr(getattr(app, "state", None), "tenant_id", None)
if not tenant_id:
return
# Record the matched route TEMPLATE (e.g. "/v1/projects/{project_id}/tools/{tool_id}/test")
# rather than the concrete path. The concrete path carries UUIDs that overflow the action
# column (String(80)) and make actions un-aggregatable. FastAPI sets scope["route"] during
# routing, which ran inside self.app (already returned); fall back to the concrete path when
# nothing matched (404) or a non-APIRoute (bare Mount) handled it. The concrete path is kept
# in meta so the specific resource is still recoverable for forensics.
route_template = getattr(scope.get("route"), "path", None) or path
await AuditService.log(
tenant_id=tenant_id, action=f"{scope['method']} {route_template}", actor_id=actor_id,
ip=_client_ip(scope, headers), status="ok",
meta={"status_code": status_code["v"], "path": path},
)
@@ -0,0 +1,5 @@
"""Auth Providers: fetch/extract/inject credentials for downstream tool calls."""
from forge.auth_providers.resolver import AuthResolver, ResolvedAuth
__all__ = ["AuthResolver", "ResolvedAuth"]
+34
View File
@@ -0,0 +1,34 @@
"""Extract values from a token-fetch response (header / cookie / json path)."""
from __future__ import annotations
from typing import Any
import httpx
def _json_path(data: Any, path: str) -> Any:
cur = data
for part in path.split("."):
if isinstance(cur, dict):
cur = cur.get(part)
elif isinstance(cur, list) and part.isdigit():
i = int(part)
cur = cur[i] if 0 <= i < len(cur) else None
else:
return None
return cur
def extract_value(resp: httpx.Response, rule: dict) -> Any:
src = rule.get("from")
if src == "header":
return resp.headers.get(rule["header"])
if src == "cookie":
return resp.cookies.get(rule["cookie"])
if src == "json":
try:
return _json_path(resp.json(), rule["json_path"])
except Exception: # noqa: BLE001 - non-JSON body
return None
return None
+316
View File
@@ -0,0 +1,316 @@
"""AuthResolver - resolve an Auth Provider to headers/cookies/params for a tool call.
Caches per (provider, per-user-context-hash) with TTL (in-process here; Redis in
prod). Invalidates on 401/403 (handled by the calling tool). Per-user secrets the
widget injects arrive in `context` and are never stored (Doc 2 §11).
"""
from __future__ import annotations
import base64
import hashlib
import time
from dataclasses import dataclass, field
from typing import Any
import httpx
from sqlalchemy import select
from forge.auth_providers.extract import extract_value
from forge.auth_providers.templates import render_value
from forge.config import settings
from forge.db.base import SessionLocal
from forge.models import AuthProvider
from forge.secrets.store import SecretStore
from forge.util.http import shared_async_client
from forge.util.locks import KeyedLocks
from forge.util.ssrf import guarded_request
# Serialize a provider's (per-user) OAuth refresh so concurrent resolves don't each POST the
# one-time refresh_token and clobber the rotated bundle - the loser would then hold a token the
# IdP has already invalidated (finding i). In-process (single worker); a distributed lock is
# needed for multi-worker, same as the rest of util.locks.
_oauth_refresh_locks = KeyedLocks()
@dataclass
class ResolvedAuth:
headers: dict[str, str] = field(default_factory=dict)
cookies: dict[str, str] = field(default_factory=dict)
params: dict[str, str] = field(default_factory=dict)
expires_at: float | None = None # monotonic seconds
@property
def expired(self) -> bool:
return self.expires_at is not None and time.monotonic() >= self.expires_at
class AuthResolver:
def __init__(self, secrets: SecretStore | None = None, session_factory=SessionLocal) -> None:
self.secrets = secrets or SecretStore(session_factory)
self._sf = session_factory
self._cache: dict[str, ResolvedAuth] = {}
async def _load(self, tenant_id: str, provider_id: str) -> AuthProvider | None:
async with self._sf() as session:
return (
await session.execute(
select(AuthProvider).where(
AuthProvider.tenant_id == tenant_id, AuthProvider.id == provider_id
)
)
).scalar_one_or_none()
@staticmethod
def _key(provider_id: str, context: dict, per_user_keys: list[str]) -> str:
dims = "|".join(f"{k}={context.get(k)}" for k in sorted(per_user_keys or []))
return provider_id + "::" + hashlib.sha256(dims.encode()).hexdigest()[:16]
async def invalidate(self, key: str) -> None:
self._cache.pop(key, None)
async def resolve(
self,
*,
tenant_id: str,
project_id: str,
provider_id: str,
context: dict | None = None,
force: bool = False,
client: httpx.AsyncClient | None = None,
provider: AuthProvider | None = None,
) -> ResolvedAuth:
context = context or {}
provider = provider or await self._load(tenant_id, provider_id)
if provider is None:
raise KeyError(f"Auth provider {provider_id!r} not found")
cfg = provider.config or {}
per_user = cfg.get("per_user_context_keys", [])
# A per-user provider may also accept an INLINE token from the run context (token_ctx_key,
# for server-to-server /run forwarding). That value must vary the cache key too, or a cached
# ResolvedAuth for one caller's inline token could be served to another sharing the same
# end_user dims. The stored-connection path is unaffected (the key is absent from context).
effective_ctx_key = cfg.get("token_ctx_key") or settings.default_token_ctx_key
cache_dims = [*per_user, effective_ctx_key] if effective_ctx_key else per_user
key = self._key(provider_id, context, cache_dims)
if not force and (cached := self._cache.get(key)) and not cached.expired:
return cached
async def read(ref: str | None) -> Any:
if not ref:
return None
return await self.secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=ref)
# `credentials_ref` is the primary secret for csrf_session/custom_script, but only a
# *fallback* for bearer/api_key (and unused for basic/oauth2). A stale or missing
# fallback must not abort a provider whose own ref (token_ref/value_ref/…) resolves -
# the per-kind branches below still raise clearly if their primary ref is absent.
try:
creds = await read(provider.credentials_ref or cfg.get("credentials_ref"))
except Exception: # noqa: BLE001 - absent fallback secret is tolerated
creds = None
kind = provider.kind
resolved = ResolvedAuth()
default_ttl = cfg.get("cache_ttl_seconds", 1800)
if kind == "bearer":
token = await self._value_for(provider, cfg, read, context, shared_ref=cfg.get("token_ref"), fallback=creds)
resolved.headers[cfg.get("header_name", "Authorization")] = (
cfg.get("prefix", "Bearer ") + str(token)
)
resolved.expires_at = None if default_ttl == 0 else time.monotonic() + default_ttl
elif kind == "api_key":
value = await self._value_for(provider, cfg, read, context, shared_ref=cfg.get("value_ref"), fallback=creds)
where, name = cfg.get("in", "header"), cfg["name"]
(resolved.headers if where == "header" else resolved.params)[name] = str(value)
resolved.expires_at = None
elif kind == "basic":
user = await read(cfg.get("username_ref"))
pw = await read(cfg.get("password_ref"))
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
resolved.headers["Authorization"] = "Basic " + token
resolved.expires_at = None
elif kind == "oauth2_client_credentials":
resolved = await self._oauth2(cfg, read, client)
elif kind == "oauth2_authorization_code":
resolved = await self._oauth2_auth_code(provider, cfg, read, tenant_id, project_id, client, context)
elif kind == "csrf_session":
resolved = await self._csrf_session(cfg, {"cred": creds, "ctx": context}, client, default_ttl)
elif kind == "custom_script": # pragma: no cover - advanced/audited
raise NotImplementedError("custom_script auth requires the advanced-scripts feature flag.")
else:
raise ValueError(f"Unknown auth kind {kind!r}")
# Extra fixed headers stamped on every call (in addition to the primary auth header) - e.g. a
# constant client id + a service token defined ONCE on the provider instead of hardcoded per
# tool. Each value is a literal or a secret:// ref (resolved from the secret store), so a
# secret never has to live in plaintext in a tool's header config.
for hname, hval in (cfg.get("extra_headers") or {}).items():
resolved_val = await read(hval) if isinstance(hval, str) and hval.startswith("secret://") else hval
if resolved_val is not None:
resolved.headers[hname] = str(resolved_val)
self._cache[key] = resolved
return resolved
async def _value_for(self, provider, cfg: dict, read, context: dict, *, shared_ref, fallback):
"""The token/value for a bearer/api_key provider.
When the provider is PER-USER (config.per_user_context_keys set, e.g. ["end_user_id"]) the
value is the acting user's OWN connected credential - read from the per-user bundle each user
deposits self-service (set_user_connection), keyed by the same per_user dims. So every end
user supplies their own token and a tool acts as them downstream, with NO shared secret and
NO passthrough of the inbound (MCP/session) token. A user who hasn't connected yet resolves
to a clear "not connected" error rather than a silent miss.
Otherwise it's the shared secret ref (or the credentials_ref fallback) - the prior behavior,
preserved exactly for non-per-user providers."""
per_user = cfg.get("per_user_context_keys")
if not per_user:
return await read(shared_ref) or fallback
# Inline per-request token (server-to-server /run forwarding via X-Forge-Context) takes
# precedence over the stored connection, so ONE per-user provider serves both delivery paths:
# a chat backend forwards the user's token inline, OR the user connected it once (MCP/console).
# The provider's own token_ctx_key wins; else fall back to the deployment-wide default
# (settings.default_token_ctx_key) so an integration that always forwards the same key works
# for every per-user provider WITHOUT per-provider config (survives project re-creation).
ctx_key = cfg.get("token_ctx_key") or settings.default_token_ctx_key
if ctx_key and (context or {}).get(ctx_key):
return context[ctx_key]
name = self.bundle_secret_name(provider.id, context, per_user)
try:
bundle = await read(f"secret://proj/{name}")
except Exception: # noqa: BLE001 - "not connected" surfaces as a missing secret
bundle = None
if not isinstance(bundle, dict) or not bundle.get("access_token"):
raise KeyError(
f"Auth provider {provider.id!r} is per-user and the acting user has not connected "
f"their credential yet (and no inline token was forwarded)"
)
return bundle["access_token"]
async def _oauth2(self, cfg: dict, read, client: httpx.AsyncClient | None) -> ResolvedAuth:
data = {
"grant_type": "client_credentials",
"client_id": await read(cfg.get("client_id_ref")),
"client_secret": await read(cfg.get("client_secret_ref")),
}
if cfg.get("scope"):
data["scope"] = cfg["scope"]
if cfg.get("audience"):
data["audience"] = cfg["audience"]
client = client or shared_async_client()
# SSRF-guarded (host validated pre-connect + every redirect hop) so a tenant-configured
# token_url can't be aimed at an internal/metadata endpoint while carrying secrets (S8).
r = await guarded_request(client, "POST", cfg["token_url"], data=data, timeout=30, follow_redirects=True)
r.raise_for_status()
body = r.json()
token = body.get("access_token", "")
ttl = body.get("expires_in", cfg.get("cache_ttl_seconds", 3600))
return ResolvedAuth(headers={"Authorization": f"Bearer {token}"}, expires_at=time.monotonic() + ttl)
@staticmethod
def _per_user_suffix(context: dict | None, per_user_keys: list[str] | None) -> str:
"""A stable short hash of the per-user context dims, so each end-user's OAuth bundle is
stored under its own secret name when the provider is configured per-user (finding i)."""
if not per_user_keys:
return ""
dims = "|".join(f"{k}={(context or {}).get(k)}" for k in sorted(per_user_keys))
return "__u" + hashlib.sha256(dims.encode()).hexdigest()[:12]
@staticmethod
def bundle_secret_name(provider_id: str, context: dict | None = None,
per_user_keys: list[str] | None = None) -> str:
# Default (no per_user_keys) preserves the original single-account name.
return f"oauth_token__{provider_id}" + AuthResolver._per_user_suffix(context, per_user_keys)
async def _store_bundle(self, tenant_id: str, project_id: str, provider_id: str, bundle: dict,
*, name: str | None = None) -> None:
async with self._sf() as session:
await self.secrets.write(
session, tenant_id=tenant_id, project_id=project_id,
name=name or self.bundle_secret_name(provider_id), value=bundle, kind="oauth",
)
async def _oauth2_auth_code(
self, provider, cfg: dict, read, tenant_id: str, project_id: str,
client: httpx.AsyncClient | None, context: dict | None = None
) -> ResolvedAuth:
# Per-user bundle name when the provider keys tokens per end-user (finding i).
per_user = cfg.get("per_user_context_keys")
bundle_name = self.bundle_secret_name(provider.id, context, per_user)
bundle_ref = cfg.get("token_bundle_ref") or f"secret://proj/{bundle_name}"
bundle = await read(bundle_ref)
if not isinstance(bundle, dict) or not bundle.get("access_token"):
raise KeyError(f"OAuth provider {provider.id!r} is not connected - run the connect flow first")
now = time.time()
expires_at = bundle.get("expires_at")
if expires_at and now >= (float(expires_at) - 60) and bundle.get("refresh_token"):
# Serialize refresh per (tenant, provider, per-user bundle) so concurrent resolves
# don't race the one-time refresh_token; re-read inside the lock in case a peer
# already refreshed it.
lock = await _oauth_refresh_locks.acquire_cm(f"{tenant_id}:{provider.id}:{bundle_name}")
async with lock:
fresh = await read(bundle_ref)
if isinstance(fresh, dict) and fresh.get("access_token"):
bundle = fresh
expires_at = bundle.get("expires_at")
if expires_at and time.time() >= (float(expires_at) - 60) and bundle.get("refresh_token"):
bundle = await self._refresh_oauth(provider, cfg, read, bundle, tenant_id,
project_id, client, bundle_name=bundle_name)
expires_at = bundle.get("expires_at")
header = cfg.get("header_name", "Authorization")
prefix = cfg.get("prefix", "Bearer ")
ttl_left = (float(expires_at) - now) if expires_at else None
cache_exp = time.monotonic() + max(0.0, ttl_left - 60) if ttl_left and ttl_left > 0 else None
return ResolvedAuth(headers={header: prefix + str(bundle["access_token"])}, expires_at=cache_exp)
async def _refresh_oauth(self, provider, cfg: dict, read, bundle: dict, tenant_id, project_id,
client, *, bundle_name: str | None = None) -> dict:
data = {
"grant_type": "refresh_token",
"refresh_token": bundle["refresh_token"],
"client_id": await read(cfg.get("client_id_ref")),
"client_secret": await read(cfg.get("client_secret_ref")),
}
client = client or shared_async_client()
r = await guarded_request(
client, "POST", cfg["token_url"],
data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True,
)
r.raise_for_status()
body = r.json()
new = dict(bundle)
new["access_token"] = body.get("access_token", bundle["access_token"])
if body.get("refresh_token"):
new["refresh_token"] = body["refresh_token"]
if body.get("expires_in"):
new["expires_at"] = time.time() + int(body["expires_in"])
await self._store_bundle(tenant_id, project_id, provider.id, new, name=bundle_name)
return new
async def _csrf_session(self, cfg: dict, vars: dict, client: httpx.AsyncClient | None, default_ttl: int) -> ResolvedAuth:
fetch = render_value(cfg["token_fetch"], vars)
client = client or shared_async_client()
r = await guarded_request(
client, fetch["method"], fetch["url"],
headers=fetch.get("headers"), json=fetch.get("body"), timeout=30, follow_redirects=True,
)
r.raise_for_status()
extracted: dict[str, Any] = {}
ttl = None
for rule in cfg.get("extract", []):
val = extract_value(r, rule)
if rule.get("kind") == "ttl":
ttl = int(val) if val else None
else:
extracted[rule["name"]] = val
out = ResolvedAuth(expires_at=time.monotonic() + (ttl or default_ttl))
for rule in cfg.get("inject", []):
value = render_value(rule["value"], {"extracted": extracted})
where = rule["to"]
target = {"header": out.headers, "cookie": out.cookies, "query": out.params}[where]
target[rule["name"]] = str(value)
return out
+116
View File
@@ -0,0 +1,116 @@
"""Token-template rendering for auth recipes.
Supports `{{a.b}}` references resolved against a vars dict, e.g.
`{{cred.username}}`, `{{ctx.csrf}}`, `{{extracted.session}}`. Non-string leaves
pass through; whole-string matches preserve the resolved value's native type.
`render_value` walks a parsed JSON structure (dict/list) and additionally honors a
`{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` loop directive, so a JSON body
template can build a variable-length array from one list-valued arg (see `_render_each`).
"""
from __future__ import annotations
import re
from collections.abc import Collection
from typing import Any
_TOKEN = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}")
class MissingTemplateVar(ValueError):
"""A template referenced a variable in a STRICT namespace that isn't defined.
Raised for `{{env.*}}` when the key isn't in FORGE_TOOL_VARS for the current environment,
so a misconfigured deploy fails loudly at call/test time instead of sending a broken URL.
The lenient namespaces (ctx/input) keep their historic behavior: a missing key renders empty
/ is dropped."""
def _lookup(path: str, vars: dict, strict_ns: Collection[str] = ()) -> Any:
parts = path.split(".")
cur: Any = vars
for part in parts:
if isinstance(cur, dict):
cur = cur.get(part)
else:
cur = None
break
# A missing value under a STRICT namespace (e.g. env) is an error, not an empty string.
if cur is None and parts and parts[0] in strict_ns:
raise MissingTemplateVar(f"undefined template variable {{{{{path}}}}} (namespace '{parts[0]}')")
return cur
def _sub_one(mm: re.Match, vars: dict, strict_ns: Collection[str] = ()) -> str:
# Embedded token (not a whole-string match): stringify the resolved value. Only a missing
# value (None) becomes empty - a falsy-but-real value like 0 or False must render as "0"/
# "False", not "" (an `x or ""` here would silently drop legitimate zeros/booleans).
v = _lookup(mm.group(1), vars, strict_ns)
return "" if v is None else str(v)
def render_template(s: str, vars: dict, *, strict_ns: Collection[str] = ()) -> Any:
# `strict_ns` names namespaces whose missing keys raise MissingTemplateVar instead of
# rendering empty (used for {{env.*}}). Defaults to lenient for all namespaces.
# Whole-string single token -> preserve native type (numbers, objects).
m = _TOKEN.fullmatch(s.strip())
if m:
return _lookup(m.group(1), vars, strict_ns)
return _TOKEN.sub(lambda mm: _sub_one(mm, vars, strict_ns), s)
def has_each_directive(obj: Any) -> bool:
"""True if `obj` (a parsed JSON structure) contains a `$each` loop directive anywhere - i.e.
a dict that has "$each" as a KEY. Used to decide whether a body template needs structural
rendering; a literal "$each" appearing inside a string value is NOT a directive and must not
trigger it (that would silently change type coercion for unrelated templates)."""
if isinstance(obj, dict):
if "$each" in obj:
return True
return any(has_each_directive(v) for v in obj.values())
if isinstance(obj, list):
return any(has_each_directive(v) for v in obj)
return False
def render_value(obj: Any, vars: dict, *, allow_each: bool = False, strict_ns: Collection[str] = ()) -> Any:
"""Walk a parsed JSON structure, rendering `{{token}}` leaves. `$each` loop directives are
honored ONLY when `allow_each=True` (the REST body-template path opts in); every other caller
- auth token_fetch/extract rules, data-node payloads - passes the default False, so a literal
object key named "$each" stays an ordinary key instead of being reinterpreted as a loop.
`strict_ns` propagates the fail-loud namespaces (e.g. env) to every leaf."""
if isinstance(obj, str):
return render_template(obj, vars, strict_ns=strict_ns)
if isinstance(obj, dict):
if allow_each and "$each" in obj:
return _render_each(obj, vars, strict_ns=strict_ns)
return {k: render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for k, v in obj.items()}
if isinstance(obj, list):
return [render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for v in obj]
return obj
def _render_each(directive: dict, vars: dict, *, strict_ns: Collection[str] = ()) -> list:
"""Expand a `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` loop directive into a
list: render `$do` once per item of the array `$each` resolves to, with the item bound under
the `$as` name (default "item"). Outer vars (input/ctx/state) stay visible inside the loop, so
a nested template can still read e.g. `{{input.orderId}}`. A missing/None `$each` yields [];
a single non-list value is treated as one item.
This lets a JSON body template build a variable-length array (e.g. one productRow per edited
cell) WITHOUT string-concatenating JSON - so the output is always valid JSON with native types
preserved, and one tool call can carry many rows instead of one call per row.
"""
each = directive.get("$each")
seq = render_value(each, vars, strict_ns=strict_ns) if isinstance(each, str) else each
if seq is None:
items: list = []
elif isinstance(seq, list):
items = seq
else:
items = [seq]
as_name = directive.get("$as") or "item"
body = directive.get("$do")
# allow_each=True so a `$do` body can itself contain a nested `$each` (loops within loops).
return [render_value(body, {**vars, as_name: item}, allow_each=True, strict_ns=strict_ns) for item in items]
+1
View File
@@ -0,0 +1 @@
"""Channel adapters."""
+238
View File
@@ -0,0 +1,238 @@
"""Email channel - inbound parsing and outbound (SMTP) replies.
Inbound supports two shapes:
- A raw RFC-822 message (bytes/str), e.g. from an IMAP poll.
- A provider inbound-parse payload (Mailgun/SendGrid/Postmark post form/JSON fields).
Outbound sends a threaded reply via SMTP (creds resolved from the channel's secret
refs). SMTP/IMAP run in worker threads so they don't block the event loop.
"""
from __future__ import annotations
import asyncio
import email
import logging
import re
import smtplib
from email.message import EmailMessage
from email.utils import make_msgid, parseaddr
from html.parser import HTMLParser
from typing import Any
from forge.channels.retry import retry_send
from forge.secrets.store import SecretStore
log = logging.getLogger("forge.channels.email")
_BLOCK_ENDERS = {"br", "p", "div", "li", "tr"}
_SKIP_TAGS = {"script", "style"}
class _HTMLTextExtractor(HTMLParser):
"""Pull visible text out of HTML with the stdlib parser instead of regexes, so a hostile
body can't trigger catastrophic backtracking (ReDoS): skip <script>/<style> content, turn
common block-enders into newlines, and let the parser unescape entities (convert_charrefs)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: Any) -> None:
if tag in _SKIP_TAGS:
self._skip_depth += 1
elif tag == "br":
self._parts.append("\n")
def handle_startendtag(self, tag: str, attrs: Any) -> None:
if tag == "br":
self._parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag in _SKIP_TAGS:
if self._skip_depth:
self._skip_depth -= 1
elif tag in _BLOCK_ENDERS:
self._parts.append("\n")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self._parts.append(data)
def get_text(self) -> str:
return "".join(self._parts)
def _html_to_text(html: str) -> str:
"""Best-effort HTML -> plain text WITHOUT a new dependency, so an HTML-only email's body
reaches the workflow instead of an empty string (audit F). Parser-based (not regex) to stay
ReDoS-safe on attacker-controlled input."""
if not html:
return ""
parser = _HTMLTextExtractor()
try:
parser.feed(html)
parser.close()
except Exception: # noqa: BLE001 - malformed HTML must never crash the public inbound webhook
pass
text = parser.get_text()
# collapse horizontal whitespace and excess blank lines; every pattern here is linear.
text = re.sub(r"[ \t\f\v]+", " ", text)
text = re.sub(r" *\n *", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _thread_ref(references: str | None, in_reply_to: str | None, message_id: str | None) -> str | None:
"""A stable per-conversation key for threading replies (audit F6): the ROOT of the
References chain (shared by every message in the thread), else the In-Reply-To parent,
else this message's own id (a brand-new thread)."""
if references:
first = references.split()[0].strip() if references.split() else ""
if first:
return first
return (in_reply_to or "").strip() or (message_id or "").strip() or None
def _merge_references(references: str | None, parent_id: str | None) -> str:
"""The outbound `References` header: the inbound References chain with the parent's
Message-ID appended (RFC 5322). Clients thread on the full References chain, not just
In-Reply-To, so preserving the whole chain keeps deep threads together (audit G)."""
ids: list[str] = []
for tok in (references or "").split():
tok = tok.strip()
if tok and tok not in ids:
ids.append(tok)
if parent_id:
pid = parent_id.strip()
if pid and pid not in ids:
ids.append(pid)
return " ".join(ids)
def parse_inbound(payload: Any) -> dict:
"""Normalize an inbound email (raw MIME or provider dict) to a common shape."""
if isinstance(payload, dict):
# Provider inbound-parse fields vary; accept the common ones.
sender = payload.get("from") or payload.get("sender") or payload.get("From", "")
subject = payload.get("subject") or payload.get("Subject", "")
text = payload.get("text") or payload.get("body-plain") or payload.get("stripped-text") or payload.get("TextBody") or ""
if not (text or "").strip():
# HTML-only email: fall back to the provider's HTML field, stripped to text, so the
# workflow gets the body instead of an empty message (audit F).
html = payload.get("html") or payload.get("body-html") or payload.get("HtmlBody") or payload.get("stripped-html") or ""
text = _html_to_text(html)
msg_id = payload.get("message-id") or payload.get("Message-Id") or payload.get("MessageID")
references = payload.get("References") or payload.get("references")
in_reply_to = payload.get("In-Reply-To") or payload.get("in-reply-to")
return {"from_addr": parseaddr(sender)[1] or sender, "from_name": parseaddr(sender)[0],
"subject": subject, "text": (text or "").strip(), "message_id": msg_id,
"references": references,
"thread_ref": _thread_ref(references, in_reply_to, msg_id)}
raw = payload.encode() if isinstance(payload, str) else payload
msg = email.message_from_bytes(raw)
body = ""
html_body = ""
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
if "attachment" in str(part.get("Content-Disposition", "")):
continue
# decode=True returns None for a part with no decodable payload - guard it so a
# malformed MIME message can't 500 the public inbound webhook (audit F-low).
if ctype == "text/plain" and not body:
raw_payload = part.get_payload(decode=True) or b""
body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
elif ctype == "text/html" and not html_body:
raw_payload = part.get_payload(decode=True) or b""
html_body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
else:
payload_bytes = msg.get_payload(decode=True) or b""
decoded = payload_bytes.decode(msg.get_content_charset() or "utf-8", "replace")
if msg.get_content_type() == "text/html":
html_body = decoded
else:
body = decoded
# No text/plain part -> fall back to the HTML part, stripped to text (audit F).
if not body.strip() and html_body:
body = _html_to_text(html_body)
name, addr = parseaddr(msg.get("From", ""))
return {"from_addr": addr, "from_name": name, "subject": msg.get("Subject", ""),
"text": body.strip(), "message_id": msg.get("Message-ID"),
"references": msg.get("References"),
"thread_ref": _thread_ref(msg.get("References"), msg.get("In-Reply-To"), msg.get("Message-ID"))}
def build_input_text(parsed: dict, include_subject: bool = True) -> str:
if include_subject and parsed.get("subject"):
return f"Subject: {parsed['subject']}\n\n{parsed.get('text', '')}"
return parsed.get("text", "")
def build_reply(*, to_addr: str, subject: str, body: str, from_addr: str,
in_reply_to: str | None = None, references: str | None = None,
message_id: str | None = None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = to_addr
msg["Subject"] = subject if subject.lower().startswith("re:") else f"Re: {subject}" if subject else "Re:"
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
# References = the inbound chain + the parent id, so deep threads stay grouped (audit G).
refs = _merge_references(references, in_reply_to)
if refs:
msg["References"] = refs
# Set an explicit Message-ID so a downstream reply can reference THIS message and clients
# can dedupe (a missing Message-ID makes some MTAs generate an unstable one) (audit G).
try:
msg["Message-ID"] = message_id or make_msgid()
except Exception: # noqa: BLE001 - make_msgid can fail on odd hostnames; header is optional
pass
msg.set_content(body)
return msg
def _send_sync(host: str, port: int, username: str | None, password: str | None, use_tls: bool, msg: EmailMessage) -> None:
with smtplib.SMTP(host, port, timeout=30) as server:
if use_tls:
server.starttls()
if username and password:
server.login(username, password)
server.send_message(msg)
async def send_reply(channel, parsed: dict, answer: str) -> bool:
"""Send the workflow's answer as a threaded SMTP reply, with bounded retry + backoff.
Returns True when an email was actually sent, False when SMTP isn't configured / there's
nowhere to send (a no-op, NOT a failure). Raises after exhausting retries so the caller can
record a real delivery status and avoid marking a handoff 'answered' on a failed send (E)."""
cfg = channel.config or {}
smtp = cfg.get("smtp") or {}
host = smtp.get("host")
if not host or not parsed.get("from_addr"):
return False
secrets = SecretStore()
username = smtp.get("username")
password = None
if smtp.get("password_ref"):
try:
password = await secrets.read_ref(tenant_id=channel.tenant_id, project_id=channel.project_id, ref=smtp["password_ref"])
except Exception: # noqa: BLE001
password = None
from_addr = smtp.get("from") or username or "bot@forge.local"
msg = build_reply(to_addr=parsed["from_addr"], subject=parsed.get("subject", ""), body=answer,
from_addr=from_addr, in_reply_to=parsed.get("message_id"),
references=parsed.get("references"))
port = int(smtp.get("port", 587))
use_tls = bool(smtp.get("use_tls", True))
pw = str(password) if password else None
async def _attempt(_n: int) -> bool:
await asyncio.to_thread(_send_sync, host, port, username, pw, use_tls, msg)
return True
await retry_send(_attempt, label=f"email->{parsed['from_addr']}")
return True
+72
View File
@@ -0,0 +1,72 @@
"""Bounded retry + backoff for outbound channel delivery (audit E).
Outbound email (SMTP) deliveries are transient-failure prone
(a relay hiccup, a 429, a 5xx). Previously a single failure was swallowed and the reply was
lost - and worse, a handoff was still marked 'answered'. `retry_send` gives every outbound
delivery a small, jittered exponential backoff and re-raises the last error when exhausted so
the caller can record a real delivery status.
"""
from __future__ import annotations
import asyncio
import logging
import random
from collections.abc import Awaitable, Callable
from typing import TypeVar
from forge.config import settings
log = logging.getLogger("forge.channels.retry")
# Outbound-delivery retry policy (env-overridable: FORGE_CHANNEL_SEND_MAX_ATTEMPTS /
# _BACKOFF_BASE_SECONDS).
CHANNEL_SEND_MAX_ATTEMPTS = settings.channel_send_max_attempts
CHANNEL_SEND_BACKOFF_BASE_SECONDS = settings.channel_send_backoff_base_seconds
CHANNEL_SEND_MAX_BACKOFF_SECONDS = 8.0
T = TypeVar("T")
class ChannelDeliveryError(Exception):
"""A retryable outbound-delivery failure. `retry_after` (seconds), when set, overrides the
computed backoff for the next attempt - used to honor an HTTP `Retry-After` on a 429."""
def __init__(self, message: str, *, retry_after: float | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after
async def retry_send(
fn: Callable[[int], Awaitable[T]],
*,
attempts: int = CHANNEL_SEND_MAX_ATTEMPTS,
base_delay: float = CHANNEL_SEND_BACKOFF_BASE_SECONDS,
label: str = "channel send",
) -> T:
"""Call async `fn(attempt)` up to `attempts` times with jittered exponential backoff.
`fn` raises to signal a retryable failure; raise `ChannelDeliveryError(retry_after=…)` to
request an explicit wait (server-directed, e.g. a 429). Returns `fn`'s result on the first
success; re-raises the last exception once attempts are exhausted."""
last_exc: BaseException | None = None
for attempt in range(1, max(1, attempts) + 1):
try:
return await fn(attempt)
except Exception as e: # noqa: BLE001 - classify + backoff below
last_exc = e
if attempt >= attempts:
break
retry_after = getattr(e, "retry_after", None)
if retry_after is not None:
delay = float(retry_after)
else:
delay = min(base_delay * (2 ** (attempt - 1)), CHANNEL_SEND_MAX_BACKOFF_SECONDS)
delay += random.uniform(0, base_delay) # noqa: S311 - jitter, not crypto
log.warning(
"%s attempt %d/%d failed (%s); retrying in %.1fs",
label, attempt, attempts, type(e).__name__, delay,
)
await asyncio.sleep(delay)
assert last_exc is not None
raise last_exc
+537
View File
@@ -0,0 +1,537 @@
"""Application settings - environment-driven (pydantic-settings).
Local defaults need **no external infra** (SQLite + embedded Chroma + in-process cache).
Every value can be overridden via `.env` or real env vars; the production swaps
(Postgres, Redis, Vault) are pure configuration changes - no code changes.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Annotated
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
# Repo layout: apps/api/forge/config.py -> parents[3] == repo root. In the container the
# tree is flattened to /app/forge, so parents[3] doesn't exist; fall back to API_ROOT (=/app).
# The Docker image bakes the schemas at /app/packages/schemas (apps/api/Dockerfile:
# `COPY packages/schemas ./packages/schemas`), so the default _DEFAULT_SCHEMAS_DIR
# (/app/packages/schemas) resolves with no FORGE_SCHEMAS_DIR override. Set FORGE_SCHEMAS_DIR
# only to point at an out-of-tree schemas copy.
_HERE = Path(__file__).resolve()
API_ROOT = _HERE.parents[1]
REPO_ROOT = _HERE.parents[3] if len(_HERE.parents) > 3 else API_ROOT
_DEFAULT_SCHEMAS_DIR = REPO_ROOT / "packages" / "schemas"
_DEFAULT_DATA_DIR = API_ROOT / ".data"
# Minimum length for a non-empty FORGE_SERVICE_API_TOKEN (enforced by the production guard).
_MIN_SERVICE_TOKEN_LEN = 24
def _as_str_list(v: object) -> list[str]:
"""Parse a list-of-strings setting from env leniently: a JSON array (["a","b"]), a
comma-separated string (a,b), or blank ("" -> []). Env/compose quoting makes strict-JSON
list fields brittle - a stray bracket or space (e.g. from a ${VAR:-[]} interpolation)
otherwise crashes startup - so we normalize here instead of requiring valid JSON."""
if v is None:
return []
if isinstance(v, (list, tuple)):
return [str(x) for x in v]
s = str(v).strip()
if not s:
return []
if s.startswith("["):
import json as _json
try:
parsed = _json.loads(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed]
except ValueError:
pass # not valid JSON (e.g. unquoted, or a mangled "[") -> strip brackets + split
s = s.strip("[]")
return [p.strip().strip('"').strip("'") for p in s.split(",") if p.strip().strip('"').strip("'")]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="FORGE_",
# ONE .env, at the repo root - the same file docker-compose reads for ${...}
# substitution - so both run modes (.venv api and the Docker stack) are configured in a
# single place. In the flattened container image REPO_ROOT == /app (no .env is copied
# there); env then comes from the compose `environment:` block and pydantic simply skips
# the missing file. Real env vars still take precedence over the file either way.
env_file=(REPO_ROOT / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
# List-of-strings settings are marked NoDecode (skip pydantic-settings' strict JSON decode)
# and parsed here so env/compose values may be JSON, comma-separated, or blank - see
# _as_str_list. Keeps a stray bracket/space from an interpolated default from crashing boot.
@field_validator(
"jwt_secret_previous", "cors_origins", "egress_allow_hosts", "egress_deny_hosts",
"egress_allow_private_hosts", "trusted_proxies", "trusted_hosts",
"mcp_stdio_allowed_commands", mode="before",
)
@classmethod
def _parse_str_lists(cls, v: object) -> list[str]:
return _as_str_list(v)
# tool_vars is a JSON MAP (not a list), so it has its own before-validator: an env var may be
# unset/blank (-> {}) or a JSON object string. Marked NoDecode so pydantic-settings does not
# crash on a blank value before we get here; a malformed JSON object still fails loudly.
@field_validator("tool_vars", mode="before")
@classmethod
def _parse_tool_vars(cls, v: object) -> dict[str, str]:
if v is None:
return {}
if isinstance(v, dict):
return {str(k): str(val) for k, val in v.items()}
s = str(v).strip()
if not s:
return {}
import json as _json
parsed = _json.loads(s)
if not isinstance(parsed, dict):
raise ValueError('FORGE_TOOL_VARS must be a JSON object, e.g. {"api_base":"https://..."}')
return {str(k): str(val) for k, val in parsed.items()}
# --- App ---
app_name: str = "Forge"
environment: str = "development"
debug: bool = True
api_v1_prefix: str = "/v1"
# --- Persistence (SQLite default; Postgres is a config-only swap) ---
database_url: str = Field(
default_factory=lambda: f"sqlite+aiosqlite:///{(_DEFAULT_DATA_DIR / 'forge.db').as_posix()}"
)
# LangGraph durable-execution checkpointer (sqlite file or 'memory').
checkpoint_db: str = Field(
default_factory=lambda: (_DEFAULT_DATA_DIR / "checkpoints.sqlite").as_posix()
)
# --- Vectors ---
# Backend for the embedding store. "chroma" (default) is the embedded persistent client -
# zero-infra, but single-writer (one process owns the on-disk index), so it does NOT share
# across workers. "pgvector" stores vectors in Postgres (reusing `database_url`) behind the
# same interface, so every worker reads/writes the same vectors - the production choice.
# pgvector requires Postgres with the `vector` extension available.
vector_backend: str = Field(default="chroma") # chroma | pgvector
chroma_path: str = Field(default_factory=lambda: (_DEFAULT_DATA_DIR / "chroma").as_posix())
# Cache dir for the local fastembed embedder's model files. Set to a baked path in the
# Docker image (see apps/api/Dockerfile) so the default model ships with the image - no
# first-run download / network dependency. None -> fastembed's own default (a temp dir),
# fine for local dev.
fastembed_cache_dir: str | None = None
# --- Cache / queue (in-process locally; Redis in prod) ---
redis_url: str | None = None # None => in-process fakes
# --- Secrets (Fernet master key; file-backed locally, KMS/Vault in prod) ---
secret_key_file: str = Field(default_factory=lambda: (_DEFAULT_DATA_DIR / "master.key").as_posix())
# --- Platform auth (JWT) ---
jwt_secret: str = "dev-insecure-change-me"
# Previously-active signing secrets, still ACCEPTED for verification (not for minting),
# so you can rotate `jwt_secret` without invalidating every live token: set the new key
# here-as-previous during the overlap window, then drop it. Tokens carry a `kid` header.
jwt_secret_previous: Annotated[list[str], NoDecode] = []
jwt_key_id: str = "k1"
jwt_algorithm: str = "HS256"
# Shorter access-token lifetime bounds the blast radius of a leaked token (audit S11);
# the 30-day refresh token (rotated on use) keeps sessions alive without re-login.
access_token_ttl_minutes: int = 60 * 8
refresh_token_ttl_days: int = 30
# Static service token for trusted server-to-server integrations (e.g. an app backend that
# drives runs on behalf of its users). Sent as `Authorization: Bearer <token>`; when it
# matches, the request authenticates as a least-privilege (editor) service identity in the
# seeded workspace - no expiry, revoke by rotating this value. Empty = disabled. This is the
# outer "is this call from our backend?" barrier; per-user / per-API auth is handled
# separately (e.g. session+CSRF injected per-run into tools). Keep it long, random, secret.
service_api_token: str = ""
# Auth is ON by default - the app behaves like production (real login required), so the
# flow is actually exercised in dev. The seeded owner (bootstrap_admin_email/password
# below) lets you log in immediately; self-service signup creates additional workspaces.
# (Public surfaces - webhooks/MCP/OAuth callback - authenticate by their own key,
# never the JWT, so they keep working regardless.)
auth_required: bool = True
# Allow open self-service signup. When False, only an existing admin can invite users.
allow_open_signup: bool = True
# Public base URL of THIS API (for OAuth redirect URIs + channel webhooks). Must match
# what you register with each OAuth provider, e.g. https://forge.yourco.com.
public_base_url: str = "http://localhost:8000"
# Public URL of the web console (where the SPA is served). Used to build invite links
# emailed to new teammates, e.g. https://app.forge.yourco.com.
public_console_url: str = "http://localhost:3000"
# Expose Forge's MCP server as an OAuth 2.1 resource + authorization server (MCP authorization
# spec). OFF by default: the /.well-known discovery, register/authorize/token endpoints, and the
# OAuth 401 challenge are only served when enabled, so an operator opts in AFTER reviewing the
# flow (consent UX, registered-client trust, MFA/multi-workspace login edge cases).
mcp_oauth_enabled: bool = False
# Deployment-wide fallback for a per-user auth provider's `token_ctx_key`. When a per-user
# bearer provider does NOT set its own `token_ctx_key`, the resolver reads the forwarded inline
# token from THIS run-context key (sent by a server-to-server caller in `X-Forge-Context`). Lets
# an integration that always forwards the same key (e.g. {"user_token": "..."}) work in EVERY
# project without configuring each provider by hand (survives project re-creation).
# Empty/None = off (per-provider `token_ctx_key` only). Env: FORGE_DEFAULT_TOKEN_CTX_KEY.
default_token_ctx_key: str | None = None
# --- Outbound email (SMTP) - used for team invites & notifications. When smtp_host is
# unset, email sending is a no-op and the API returns the invite link so an admin can
# share it manually. Point at any SMTP relay (Postmark/SendGrid/SES/Mailgun/etc.). ---
smtp_host: str | None = None
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool = True
smtp_from: str = "Forge <no-reply@forge.local>"
# The seeded workspace owner. The default password lets you log in straight away in dev
# (email: you@forge.local · password: forge-admin); CHANGE IT in production (the prod
# guard rejects the default password).
bootstrap_admin_email: str = "you@forge.local"
bootstrap_admin_password: str | None = "forge-admin"
# --- Schemas (shared contract, packages/schemas) ---
schemas_dir: str = Field(default_factory=lambda: _DEFAULT_SCHEMAS_DIR.as_posix())
# --- Tools ---
# Per-environment substitution values exposed to tool/auth endpoint templates as {{env.*}}
# (e.g. {{env.api_base}} in a REST tool's url_template, or a GraphQL endpoint). ONE JSON map,
# so the SAME tool/auth DB row works in every environment - only this value changes per deploy.
# Add one entry per downstream system. A template that references a key NOT in this map fails
# the call with a clear error (rather than silently sending a broken URL).
# Env: FORGE_TOOL_VARS='{"api_base":"https://api.example.com"}'. Blank/unset = {}.
tool_vars: Annotated[dict[str, str], NoDecode] = Field(default_factory=dict)
# Code tools run RestrictedPython (AST-sandboxed) but NOT OS-isolated: no CPU/memory
# bound and a runaway thread can't be force-killed. RestrictedPython is a hardening
# layer, not a sandbox, so it is OFF by default. Only enable it on a trusted, single-
# tenant install, or once an isolated executor (subprocess/container/gVisor) is wired
# in. The production guard refuses to boot with this on unless explicitly acknowledged.
enable_code_tools: bool = False
# Set true to run code tools in production despite the lack of OS isolation (you accept
# the in-process RCE/DoS risk - e.g. a trusted single-tenant deployment).
allow_unsandboxed_code_tools: bool = False
# External MCP servers reached via the `stdio` transport launch a LOCAL PROCESS
# (command + args) - i.e. arbitrary command execution on the API host, like an
# unsandboxed code tool. OFF by default; enable only on a trusted single-tenant install.
# Optionally restrict to an allow-list of executables (empty = any command when enabled).
enable_mcp_stdio: bool = False
mcp_stdio_allowed_commands: Annotated[list[str], NoDecode] = []
# Prune the assistant's ~19 tools to the relevant subset per turn (cuts tool-schema
# tokens). Opt-in: the selection itself is an extra model call, so it's a tradeoff.
assistant_tool_selector: bool = False
# Hard cap on a tool response handed to the model when no projection trims it
# (token-cost guard). 0 = no cap.
max_tool_response_chars: int = 20000
# Default per-request timeout (seconds) for REST/HTTP tools when the tool config doesn't
# set its own `timeout_seconds`.
tool_request_timeout_seconds: int = 30
# Max redirect hops a REST tool follows when follow_redirects is on. Each hop is
# re-validated against the SSRF egress guard, so this bounds a redirect loop / chain.
tool_max_redirects: int = 5
# Best-effort IPv6/AAAA-lookup suppression fallback (see forge.util.netfix). The AUTHORITATIVE
# fix for the slow-cold-DNS penalty is `RES_OPTIONS=no-aaaa` in the process env, set by
# docker-compose - it works under uvloop (which the server uses) where a Python resolver patch
# cannot. This toggle only enables the code-level fallback for launch paths without that env
# var (e.g. a local .venv run). Disable (FORGE_PREFER_IPV4_EGRESS=false) for an IPv6-only egress
# network (and clear RES_OPTIONS in that case too).
prefer_ipv4_egress: bool = True
# Reuse ONE warm keep-alive HTTP connection pool for OpenAI model calls across runs, instead
# of the fresh client each per-run graph compile would otherwise build (a new TLS handshake on
# the first call of every run). No cost/token impact - purely a transport optimization that
# also lowers server load (fewer handshakes/sockets). Off => each model builds its own client.
llm_http_keepalive: bool = True
# Auto-attach AnthropicPromptCachingMiddleware to Anthropic-model agents (caches the
# static system-prompt/tools prefix; large multi-turn cost saving). Off => opt-in only.
default_anthropic_prompt_caching: bool = True
# Minimum cosine similarity for a long-term-memory `recall` hit to be returned. 0 = off
# (return the top_k nearest regardless of distance). Raise (~0.3-0.5 for the default BGE
# embedder) to stop unrelated memories polluting the prompt.
memory_recall_min_score: float = 0.0
# --- Semantic cache / HITL / channel delivery (framework-configurable) ---
# Cosine threshold for a semantic-cache hit (the `semantic_cache` agent middleware) and its
# entry TTL. Per-agent config can override; these are the deployment defaults.
semantic_cache_threshold: float = 0.95
semantic_cache_ttl_seconds: int = 3600
# How long a HITL-interrupted run may wait for a human before the reaper expires it (fails
# the run + closes the handoff with the on_error fallback). 0 = never expire (prior behavior).
hitl_approval_timeout_seconds: int = 0
# Hard per-run wall-clock ceiling (cooperative, checked between stream frames). 0 = unlimited.
run_wall_clock_timeout_seconds: int = 0
# Outbound channel delivery (email SMTP) retry policy.
channel_send_max_attempts: int = 3
channel_send_backoff_base_seconds: float = 0.5
# --- Models ---
default_model: str = "fake:echo" # offline-safe default; set a real provider model in prod
request_timeout_seconds: int = 600
# LangGraph checkpoint durability for runs: "async" (default - persist while the
# next step executes), "sync" (persist before next step), or "exit" (persist only
# at the end; fastest, but HITL interrupts mid-run rely on per-step checkpoints,
# so keep async/sync when using human_input nodes).
run_durability: str = "async"
# Floor for LangGraph's per-run superstep budget (recursion_limit). LangGraph's own
# default is 25, which a Loop node (each iteration ~= loop->router->body, ~3 supersteps)
# blows past after only a handful of iterations, raising GraphRecursionError mid-run. The
# actual limit used is max(this floor, a value derived from workflow size + loop max_iter),
# so large graphs/loops scale automatically. Raise the floor for very deep workflows.
graph_recursion_limit: int = 100
# --- Observability (OpenTelemetry export; point at an OTLP collector or Langfuse) ---
otel_enabled: bool = False
otel_exporter_otlp_endpoint: str | None = None
otel_service_name: str = "forge"
# Expose the unauthenticated /metrics (Prometheus counters) and /version (dependency
# versions) endpoints. OFF by default: these are an internal operational surface that also
# aids fingerprinting, so enable only where the scrape endpoint sits on a trusted network.
expose_metrics: bool = False
# --- Tool I/O in traces (debug what an agent actually sent a tool) ---
# Master switch: capture per-tool-call input/output on trace spans (the LLM's tool args,
# and for REST tools the FRAMED request - method, resolved URL, query, headers, cookies,
# body - plus the response status/latency/body). Lets you see whether the agent attached
# proper input, and why a call that "works in test" 401s in a run (e.g. a {{ctx.*}} cookie
# that never arrived and was silently dropped). Admin-only dashboard surface.
trace_tool_io: bool = True
# A REST request/response captured for a trace can contain LIVE session cookies, CSRF
# tokens, and Authorization headers. Off (default) stores full values for debugging; set
# true on a shared/production install to MASK the values of sensitive headers/cookies
# (presence + length kept, e.g. "••• (32 chars)") so secrets aren't persisted in traces.
trace_tool_io_redact: bool = False
# Per-field clip so a large body/response can't bloat the spans table. 0 = no cap.
trace_tool_io_max_chars: int = 20000
# In-process scheduler for `schedule` / `app_event` triggers (fires due ones once a minute).
# ON by default so a published schedule actually fires out of the box (the manual documents
# this; off-by-default silently no-op'd every schedule). Dispatch now claims each due trigger
# atomically (re-check + stamp last_fired_at in one txn) before running it, so an accidental
# multi-leader setup can't double-fire. For multi-replica prod, still elect ONE leader:
# `scheduler_leader` lets you ship the same image everywhere and set FORGE_SCHEDULER_LEADER
# =false on the non-leaders. Set FORGE_ENABLE_SCHEDULER=false to disable entirely.
enable_scheduler: bool = True
scheduler_leader: bool = True
# Seed demo data (projects/tools/auth) on first run. Off => start from an empty
# workspace and create projects yourself. Set FORGE_SEED_DEMO=true to populate.
seed_demo: bool = False
# --- Versioning (entity change history) ---
# How many recent versions to retain per entity (workflows, agents, tools, components,
# auth providers, knowledge sources, project settings). Older versions are pruned on each
# new snapshot. 0 = keep all (no pruning). Overridable per-tenant via tenant.settings
# ("version_history_limit"); exposed in the console Settings > Versioning panel.
version_history_limit: int = 5
# --- CORS ---
cors_origins: Annotated[list[str], NoDecode] = ["http://localhost:3000", "http://127.0.0.1:3000"]
# --- Egress / SSRF guard (applies to REST/GraphQL tools, webhooks, web_fetch,
# URL ingestion, and auth/OAuth token fetches). block_private rejects URLs that
# resolve to private/loopback/link-local/metadata addresses. allow/deny host
# lists match a host or any parent domain (e.g. "example.com" covers
# "api.example.com"). Per-project overrides live in project.config.egress. ---
egress_block_private: bool = True
egress_allow_hosts: Annotated[list[str], NoDecode] = []
egress_deny_hosts: Annotated[list[str], NoDecode] = []
# Hosts permitted to resolve to a PRIVATE / loopback / link-local address even while
# block_private is on (default-deny, explicit-allow). Use for trusted internal targets -
# localhost during dev, an internal service, an on-prem host - WITHOUT disabling the SSRF
# guard globally (so the app still boots in production). Matches a host or any parent
# domain. Per-project override: project.config.egress.allow_private_hosts.
egress_allow_private_hosts: Annotated[list[str], NoDecode] = []
# --- Rate limits / quotas (per tenant). 0 = unlimited. Per-tenant overrides may
# live in tenant.settings (max_runs_per_minute / max_runs_per_day). ---
run_rate_limit_per_minute: int = 60
api_rate_limit_per_minute: int = 240
# Ceiling on the UNAUTHENTICATED auth endpoints (login/register/refresh/accept-invite) -
# brute-force / credential-stuffing guard (audit: login had no throttle). This is the STRICT
# per-EMAIL rate (per-account guard); the per-IP bucket is 10x looser (stuffing/DoS across
# many accounts). 0 = unlimited.
auth_rate_limit_per_minute: int = 10
# Wire api_rate_limit_per_minute as a global per-IP request ceiling (ASGI middleware).
# Health/readiness/metrics and SSE stream paths are exempt. Set false to disable the
# global guard (per-surface limits - runs/embed/auth/tools - still apply).
enable_global_rate_limit: bool = True
# Projected per-run cost (USD) reserved against the daily cost cap while a run is in flight,
# so N concurrent runs can't each pass a stale "already-spent" check and blow past the cap.
# 0 = disabled (admit on completed-cost only, prior behavior). Per-tenant override:
# tenant.settings["projected_run_cost_usd"] / ["max_cost_per_run_usd"].
projected_run_cost_usd: float = 0.0
# Timezone for the daily quota reset window (was hard-coded to UTC midnight). Per-tenant
# override: tenant.settings["reset_tz"]. Falls back to UTC where tzdata is unavailable.
quota_reset_tz: str = "UTC"
# Bounded concurrency for evaluation runs (each dataset item is a full billable run).
eval_concurrency: int = 5
# --- Public embed surface (anonymous, browser-facing). The publishable key is PUBLIC
# by design, so these are the real abuse/cost ceilings. Per-IP is the important one
# (a single key is shared by every visitor). 0 = unlimited. The daily tenant quota
# (above) is ALSO enforced on the embed path. ---
embed_rate_limit_per_minute: int = 60 # per publishable key
embed_rate_limit_per_ip_per_minute: int = 20 # per client IP (denial-of-wallet guard)
embed_stream_limit_per_ip_per_minute: int = 60 # SSE connections per IP
# Max concurrent in-flight runs per tenant (0 = unlimited). Backpressure / noisy-
# neighbour guard for the inline execution path until the worker tier is enabled.
max_concurrent_runs_per_tenant: int = 20
# How many recent turns (Trace rows) the Traces conversation list scans before grouping
# by thread in Python. Bounds the query regardless of retention; raise it for projects
# with very deep history at the cost of a wider scan.
conversation_scan_limit: int = 2000
# --- Data retention (scheduled purge; leader-only, wired into the reaper loop). Traces/
# spans/runs are purged per-project by project.config.tracing.retention_days; audit logs by
# audit_log_retention_days. 0 anywhere = keep forever (no purge). ---
enable_retention: bool = True
retention_interval_seconds: int = 3600 # how often the purge sweep runs
audit_log_retention_days: int = 0 # workspace-wide audit-log floor; 0 = keep forever
# Fallback trace/span/run retention (days) for projects that don't set
# tracing.retention_days in their config. 0 = keep forever.
default_trace_retention_days: int = 0
# Best-effort worker count (mirror your gunicorn/uvicorn --workers). Only used so the
# startup guard can WARN when >1 worker runs WITHOUT Redis - in-process rate-limit /
# idempotency / token-revocation state is per-worker and won't be shared. Also read from
# the conventional $WEB_CONCURRENCY env var.
web_concurrency: int = 1
# Reverse-proxy IPs whose X-Forwarded-For we trust for client-IP derivation. Empty =>
# trust none (use the socket peer). Set to your LB/ingress IPs in production so clients
# can't spoof their IP for per-IP rate limits / audit. "*" trusts any (only behind a
# trusted ingress that always overwrites XFF).
trusted_proxies: Annotated[list[str], NoDecode] = []
# Host allow-list for the API (TrustedHostMiddleware). Empty => allow any (dev).
trusted_hosts: Annotated[list[str], NoDecode] = []
# --- LangGraph checkpointer backend: "sqlite" (default, dev), "memory" (ephemeral),
# or "postgres" (durable, shared across workers - required for prod/HITL). When
# "postgres", set FORGE_CHECKPOINT_POSTGRES_URL (or it falls back to database_url). ---
checkpoint_backend: str = "sqlite"
checkpoint_postgres_url: str | None = None
@property
def data_dir(self) -> Path:
return _DEFAULT_DATA_DIR
def ensure_dirs(self) -> None:
_DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True)
Path(self.chroma_path).mkdir(parents=True, exist_ok=True)
# Environments treated as local/insecure-OK. ANY other value (staging, prod, an
# unknown string, or the empty string) is treated as security-enforced - so a
# misconfigured/typo'd FORGE_ENVIRONMENT fails CLOSED rather than silently skipping
# every guard.
_DEV_ENVIRONMENTS = ("development", "dev", "local", "test")
@property
def is_production(self) -> bool:
return self.environment.lower() in ("production", "prod")
@property
def enforce_security(self) -> bool:
"""True when the deployment must pass the hardening checks. Only the explicit
local-dev environment names opt out; everything else fails closed."""
return self.environment.lower() not in self._DEV_ENVIRONMENTS
def validate_production(self) -> list[str]:
"""Return a list of FATAL misconfigurations. Called at startup; an install with
any of these should refuse to serve. Enforced for every non-dev environment
(fail-closed), not just literal 'production'."""
problems: list[str] = []
if not self.enforce_security:
return problems
if self.jwt_secret in ("", "dev-insecure-change-me"):
problems.append("FORGE_JWT_SECRET is unset/default - set a strong random secret.")
if not self.auth_required:
problems.append("FORGE_AUTH_REQUIRED must be true outside local development.")
if self.bootstrap_admin_password == "forge-admin":
problems.append("FORGE_BOOTSTRAP_ADMIN_PASSWORD is the dev default - set a real one.")
if not self.egress_block_private:
problems.append("FORGE_EGRESS_BLOCK_PRIVATE must stay true outside dev (SSRF guard).")
if self.database_url.startswith("sqlite"):
problems.append("SQLite is not supported outside dev - set a Postgres FORGE_DATABASE_URL.")
if self.checkpoint_backend not in ("postgres",):
problems.append(
"FORGE_CHECKPOINT_BACKEND must be 'postgres' outside dev - a sqlite/memory "
"checkpointer loses run/HITL state on restart and can't be shared across workers."
)
if self.enable_code_tools and not self.allow_unsandboxed_code_tools:
problems.append(
"FORGE_ENABLE_CODE_TOOLS is on but code execution is not OS-isolated. Disable it, "
"or set FORGE_ALLOW_UNSANDBOXED_CODE_TOOLS=true to explicitly accept the RCE/DoS risk."
)
# A Host-header allow-list must be set outside dev (empty => TrustedHostMiddleware is
# not even added, so Host/absolute-URI spoofing is unmitigated - audit S6/host attacks).
if not self.trusted_hosts:
problems.append("FORGE_TRUSTED_HOSTS must list the API's public hostname(s) outside dev.")
# Public URLs are embedded in OAuth redirect URIs and emailed invite/reset links; they
# MUST be https in production so tokens/codes never traverse cleartext.
for name, url in (("FORGE_PUBLIC_BASE_URL", self.public_base_url),
("FORGE_PUBLIC_CONSOLE_URL", self.public_console_url)):
if not url.lower().startswith("https://"):
problems.append(f"{name} must be an https:// URL outside dev (got {url!r}).")
# A short service token is brute-forceable; if enabled at all it must be long+random.
if self.service_api_token and len(self.service_api_token) < _MIN_SERVICE_TOKEN_LEN:
problems.append(
f"FORGE_SERVICE_API_TOKEN is set but shorter than {_MIN_SERVICE_TOKEN_LEN} chars - "
"use a long random secret or leave it empty to disable server-to-server auth."
)
return problems
def _multi_worker_without_redis(self) -> bool:
import os
workers = self.web_concurrency
try:
workers = max(workers, int(os.getenv("WEB_CONCURRENCY", "0") or 0))
except ValueError:
pass
return workers > 1 and not self.redis_url
def startup_warnings(self) -> list[str]:
"""Non-fatal but dangerous configuration, logged loudly at startup regardless of
environment so an insecure local default is never silently shipped."""
warns: list[str] = []
if self.jwt_secret == "dev-insecure-change-me":
warns.append("JWT secret is the built-in dev default - tokens are forgeable. Set FORGE_JWT_SECRET.")
if self.bootstrap_admin_password == "forge-admin":
warns.append("Bootstrap admin password is the dev default. Set FORGE_BOOTSTRAP_ADMIN_PASSWORD.")
if not self.auth_required:
warns.append("auth_required is false - unauthenticated requests act as the workspace owner.")
if self.enable_code_tools:
warns.append("Code tools are enabled and run unsandboxed (RestrictedPython only).")
if self.enable_mcp_stdio:
warns.append(
"MCP stdio transport is enabled - external MCP clients can launch local processes "
"(arbitrary command execution). Restrict FORGE_MCP_STDIO_ALLOWED_COMMANDS."
)
if self.environment.lower() not in (*self._DEV_ENVIRONMENTS, "production", "prod", "staging"):
warns.append(f"Unrecognized FORGE_ENVIRONMENT={self.environment!r} - treated as security-enforced.")
if self._multi_worker_without_redis():
warns.append(
"Multiple workers configured without FORGE_REDIS_URL - rate limits, idempotency, "
"and token revocation are in-process (per-worker) and won't be shared/enforced "
"globally. Set FORGE_REDIS_URL for multi-worker deployments."
)
return warns
@lru_cache
def get_settings() -> Settings:
s = Settings()
s.ensure_dirs()
return s
settings = get_settings()
+5
View File
@@ -0,0 +1,5 @@
"""Async SQLAlchemy persistence (SQLite default; Postgres via DATABASE_URL swap)."""
from forge.db.base import Base, SessionLocal, engine, get_session, init_db
__all__ = ["Base", "SessionLocal", "engine", "get_session", "init_db"]
+120
View File
@@ -0,0 +1,120 @@
"""Async engine, session factory, and declarative base.
SQLite (aiosqlite) by default; set FORGE_DATABASE_URL to a Postgres async URL in
prod (no code change). `init_db` creates tables for dev; Alembic owns prod migrations.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import NullPool
from forge.config import settings
class Base(DeclarativeBase):
pass
def new_uuid() -> str:
return str(uuid.uuid4())
class PkTimestamp:
"""Mixin: string-UUID primary key + created/updated timestamps."""
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# SQLite needs check_same_thread off for the async driver's connection sharing.
_is_sqlite = settings.database_url.startswith("sqlite")
_connect_args = {"check_same_thread": False} if _is_sqlite else {}
# SQLite (dev/test, via aiosqlite): pooling connections across asyncio event loops - e.g. the
# per-test loops pytest-asyncio spins up - leaves a connection to be torn down in a loop other
# than the one that opened it, causing intermittent "Task was destroyed but it is pending" /
# "object NoneType can't be used in 'await'" teardown errors. NullPool opens a fresh connection
# per checkout (cheap for a local sqlite file) and closes it immediately, so nothing lingers
# across loops. Postgres (prod) keeps the default pooled behaviour - no perf change there.
_engine_kwargs = {"poolclass": NullPool} if _is_sqlite else {}
engine = create_async_engine(
settings.database_url, echo=False, future=True, connect_args=_connect_args, **_engine_kwargs
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
# --- Postgres Row-Level Security wiring --------------------------------------------------
# infra/postgres_rls.sql policies filter on current_setting('app.current_tenant'). Nothing set
# that GUC before, so applying the (FORCE) RLS policies returned ZERO rows and broke the app.
# Set it per-transaction from the request-scoped tenant contextvar (forge.db.scoping) via
# set_config(..., is_local=true) so it auto-resets at commit/rollback. Postgres-only: SQLite
# (dev/test) has no RLS, so this listener isn't attached there and the suite is unaffected.
# Defensive: a failure to set the GUC must never break the transaction.
if not _is_sqlite:
from sqlalchemy import event, text
@event.listens_for(engine.sync_engine, "begin")
def _apply_tenant_guc(conn): # pragma: no cover - only exercised against Postgres
try:
from forge.db.scoping import current_tenant
tid = current_tenant()
if tid:
conn.execute(text("SELECT set_config('app.current_tenant', :tid, true)"), {"tid": str(tid)})
except Exception: # noqa: BLE001 - RLS GUC is best-effort; never break the txn
pass
async def init_db() -> None:
# Import models so they register on Base.metadata before create_all.
from forge import models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(_ensure_new_columns)
def _ensure_new_columns(conn) -> None:
"""Dev-grade additive migration: create_all never alters existing tables, so
columns added to the ORM after a table exists must be ALTERed in. Alembic owns
real migrations in prod; this covers the SQLite dev database."""
from sqlalchemy import inspect, text
wanted = {
"kb_sources": {"folder": "VARCHAR(200) NOT NULL DEFAULT ''"},
"triggers": {"metadata": "JSON DEFAULT '{}'"},
"agents": {"created_by": "VARCHAR(36)", "created_by_email": "VARCHAR(320)"},
"mcp_clients": {"disabled_tools": "JSON DEFAULT '[]'"},
"api_keys": {"user_id": "VARCHAR(36)", "project_id": "VARCHAR(36)"},
"tool_sets": {"exposed": "BOOLEAN NOT NULL DEFAULT 1"},
"projects": {"embed_key": "VARCHAR(64)"},
"spans": {"input": "JSON", "output": "JSON"},
"runs": {"source": "VARCHAR(40) NOT NULL DEFAULT 'playground'"},
"traces": {
"source": "VARCHAR(40) NOT NULL DEFAULT 'playground'",
"actor": "VARCHAR(300) NOT NULL DEFAULT 'System'",
"end_user_id": "VARCHAR(200)",
"user_message": "TEXT",
"ai_response": "TEXT",
},
}
inspector = inspect(conn)
for table, columns in wanted.items():
if table not in inspector.get_table_names():
continue
existing = {c["name"] for c in inspector.get_columns(table)}
for col, ddl in columns.items():
if col not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}"))
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
+52
View File
@@ -0,0 +1,52 @@
"""Tenant-scoping helpers - one place to enforce row-level tenant isolation.
Every query against a tenant-scoped table should go through `tenant_scoped` so the
`tenant_id` filter can never be forgotten. On Postgres this is backed up by Row-Level
Security (see `infra/postgres_rls.sql`), which is a DB-level guarantee even if a query
slips through; SQLite (dev) relies on this query-level scoping alone.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TypeVar
from sqlalchemy import Select
T = TypeVar("T")
def tenant_scoped(stmt: Select, model, tenant_id: str, *, project_id: str | None = None) -> Select:
"""Add `WHERE tenant_id = :tenant_id` (and optional project_id) to a select."""
stmt = stmt.where(model.tenant_id == tenant_id)
if project_id is not None and hasattr(model, "project_id"):
stmt = stmt.where(model.project_id == project_id)
return stmt
# Request-scoped tenant id, read by the Postgres RLS GUC listener (see forge.db.base) so the
# `app.current_tenant` setting is populated on every transaction and infra/postgres_rls.sql
# policies actually filter rows. Set by the `current_tenant_id` dependency for authenticated
# routes and by `tenant_guard(...)` around non-request work (runs, dispatch, scheduler). It is
# defense-in-depth ON TOP OF the explicit `WHERE tenant_id=` scoping - never the only guard.
_current_tenant: ContextVar[str | None] = ContextVar("forge_current_tenant", default=None)
def set_current_tenant(tenant_id: str | None):
return _current_tenant.set(tenant_id)
def current_tenant() -> str | None:
return _current_tenant.get()
@contextmanager
def tenant_guard(tenant_id: str | None):
"""Bind the current tenant for the duration of a block (non-request code paths: run
execution, trigger dispatch, scheduler). Resets on exit so it can't leak across tasks."""
token = _current_tenant.set(tenant_id)
try:
yield
finally:
_current_tenant.reset(token)
+200
View File
@@ -0,0 +1,200 @@
"""Bootstrap + optional demo seed.
`bootstrap` always ensures a single tenant (+ owner user) exists so the app has a
tenant context - but creates NO projects, so you start from an empty workspace and
build from scratch in the UI.
`seed_demo_data` (only when FORGE_SEED_DEMO=true) populates the showcase project,
tools, auth provider, and a runnable workflow.
"""
from __future__ import annotations
from sqlalchemy import select
from forge.models import AuthProvider, Project, Tenant, Tool, User, Workflow
from forge.secrets.store import SecretStore
SEED_EXECUTABLE: dict = {
"id": "support_router",
"version": 1,
"state": {
"messages": {"type": "list[message]", "reducer": "add_messages"},
"intent": {"type": "str", "reducer": "last"},
},
"entry_node": "start",
"global_middleware": [{"type": "model_call_limit", "config": {"run_limit": 25}}],
"nodes": [
{"id": "start", "type": "start", "config": {}, "position": {"x": 40, "y": 200}},
{
"id": "intent_router",
"type": "router",
"config": {
"expression": "intent",
"cases": {"billing": "billing_agent", "technical": "tech_agent"},
"default": "billing_agent",
},
"position": {"x": 300, "y": 200},
},
{
"id": "billing_agent",
"type": "agent",
"config": {
"flavor": "agent",
"name": "billing_agent",
"model": "fake:Thanks - your billing question is resolved.",
"system_prompt": "You are the billing support agent. Be concise and helpful.",
"middleware": [
{"type": "summarization", "config": {"trigger": ["tokens", 4000]}},
{"type": "tool_call_limit", "config": {"run_limit": 3}},
],
},
"position": {"x": 600, "y": 110},
},
{
"id": "tech_agent",
"type": "agent",
"config": {
"flavor": "agent",
"name": "tech_agent",
"model": "fake:Here's how to fix your technical issue.",
"system_prompt": "You are the technical support agent.",
},
"position": {"x": 600, "y": 300},
},
{"id": "end", "type": "end", "config": {}, "position": {"x": 900, "y": 200}},
],
"edges": [
{"source": "start", "target": "intent_router"},
{"source": "billing_agent", "target": "end"},
{"source": "tech_agent", "target": "end"},
],
}
SEED_CANVAS: dict = {
"nodes": [
{"id": n["id"], "type": n["type"], "position": n.get("position", {"x": 0, "y": 0}), "data": {}}
for n in SEED_EXECUTABLE["nodes"]
],
"edges": [
{"id": f"e{i}", "source": e["source"], "target": e["target"]}
for i, e in enumerate(SEED_EXECUTABLE["edges"])
],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
_DEMO_PROJECTS = [
("Customer Support", "customer-support", "active", {"workflows": 1, "tools": 3, "runs7d": 1840}),
("Internal Ops Bot", "internal-ops-bot", "active", {"workflows": 0, "tools": 0, "runs7d": 620}),
("Sales Assistant", "sales-assistant", "active", {"workflows": 0, "tools": 0, "runs7d": 980}),
]
async def bootstrap(session) -> str:
"""Ensure exactly one tenant (+ owner user). Returns the tenant id. No projects.
The owner's password comes from FORGE_BOOTSTRAP_ADMIN_PASSWORD when set, so the
first login works once auth is enabled; otherwise the owner has no password and
is only usable via the no-auth dev fallback (or self-service register)."""
from forge.config import settings
from forge.security import hash_password
existing = (await session.execute(select(Tenant))).scalars().first()
if existing:
# Backfill a password on the seeded owner if one is now configured.
if settings.bootstrap_admin_password:
owner = (
await session.execute(
select(User).where(User.tenant_id == existing.id, User.role == "owner")
)
).scalars().first()
if owner and not owner.password_hash:
owner.password_hash = hash_password(settings.bootstrap_admin_password)
await session.commit()
return existing.id
tenant = Tenant(name="My Workspace", plan="free")
session.add(tenant)
await session.flush()
session.add(User(
tenant_id=tenant.id, email=settings.bootstrap_admin_email, role="owner",
password_hash=hash_password(settings.bootstrap_admin_password) if settings.bootstrap_admin_password else None,
))
await session.commit()
return tenant.id
async def seed_demo_data(session, tenant_id: str) -> None:
"""Populate showcase projects/tools/auth/workflow. Idempotent (skips if any project exists)."""
has_project = (
await session.execute(select(Project).where(Project.tenant_id == tenant_id))
).scalars().first()
if has_project:
return
first_project_id = None
for name, slug, status, stats in _DEMO_PROJECTS:
project = Project(
tenant_id=tenant_id, name=name, slug=slug, description=f"{name} agents and workflows.",
status=status, config={"default_model": "fake:echo", "stats": stats},
)
session.add(project)
await session.flush()
if first_project_id is None:
first_project_id = project.id
session.add(Workflow(
tenant_id=tenant_id, project_id=first_project_id, name="Support Router",
description="Intent router → billing/tech agents.",
executable=SEED_EXECUTABLE, canvas=SEED_CANVAS, status="active",
))
await SecretStore().write(
session, tenant_id=tenant_id, project_id=first_project_id,
name="orders_api_creds", kind="csrf_session",
value={"username": "svc_acme", "password": "s3cr3t"},
)
ap = AuthProvider(
tenant_id=tenant_id, project_id=first_project_id, name="orders_session", kind="csrf_session",
credentials_ref="secret://proj/orders_api_creds",
config={
"kind": "csrf_session", "credentials_ref": "secret://proj/orders_api_creds",
"token_fetch": {
"method": "POST", "url": "https://api.acme.dev/auth/login",
"headers": {"Content-Type": "application/json"},
"body": {"username": "{{cred.username}}", "password": "{{cred.password}}"},
},
"extract": [
{"name": "csrf", "from": "header", "header": "X-CSRF-Token"},
{"name": "session", "from": "cookie", "cookie": "SESSIONID"},
],
"inject": [
{"to": "header", "name": "X-CSRF-Token", "value": "{{extracted.csrf}}"},
{"to": "cookie", "name": "SESSIONID", "value": "{{extracted.session}}"},
],
"cache_ttl_seconds": 1800, "refresh_on": [401, 403],
},
)
session.add(ap)
await session.flush()
session.add(Tool(
tenant_id=tenant_id, project_id=first_project_id, name="get_order", kind="rest_api",
auth_provider_id=ap.id, last_tested="pass",
config={
"description": "Fetch an order by ID, including line items and totals.",
"request": {
"method": "GET", "url_template": "https://api.acme.dev/v2/orders/{order_id}",
"fields": [
{"path": "order_id", "type": "string", "in": "path", "required": True, "llm_visible": True, "description": "The order identifier"},
{"path": "include", "type": "string", "in": "query", "required": False, "llm_visible": False, "default": "totals,customer"},
],
"headers": [{"name": "Accept", "value": "application/json"}],
},
"response": {"projection_jmespath": "data.{subtotal: totals.subtotal, total: totals.grand_total, status: status}"},
"timeout_seconds": 30,
},
))
session.add(Tool(tenant_id=tenant_id, project_id=first_project_id, name="current_time", kind="builtin", last_tested="pass", config={"builtin": "current_time", "description": "Get the current UTC time."}))
session.add(Tool(tenant_id=tenant_id, project_id=first_project_id, name="calculator", kind="builtin", last_tested="pass", config={"builtin": "calculator", "description": "Evaluate an arithmetic expression."}))
await session.commit()
+206
View File
@@ -0,0 +1,206 @@
"""FastAPI dependencies: session, auth/tenant resolution, RBAC.
Auth rollout is gated by `settings.auth_required`:
- True → every request must carry a valid `Authorization: Bearer <access-token>`;
the user is loaded from the DB and must be `active`.
- False → requests with no token fall back to the seeded workspace owner so the
console keeps working during the migration (dev default).
`current_tenant_id` is derived from the resolved user, so every existing route that
already depends on it becomes tenant-scoped automatically.
"""
from __future__ import annotations
import hmac
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.config import settings
from forge.db import SessionLocal
from forge.security import TokenError, decode_token, tokens_revoked_after
from forge.services.auth import AuthService, role_at_least
from forge.services.runs import RunService
from forge.util.clientip import resolve_client_ip
@dataclass
class CurrentUser:
id: str
tenant_id: str
role: str
email: str | None = None
is_fallback: bool = False
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
def _bearer(request: Request) -> str | None:
auth = request.headers.get("authorization") or request.headers.get("Authorization")
if not auth:
return None
parts = auth.split(None, 1)
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return None
async def get_current_user(request: Request) -> CurrentUser:
token = _bearer(request)
if token:
# Static service token (trusted server-to-server integrations): a fixed shared secret
# that authenticates as a least-privilege service identity in the seeded workspace.
# Checked before JWT decode (it isn't a JWT); constant-time compare to avoid leaking it.
svc = settings.service_api_token
if svc and hmac.compare_digest(token, svc):
tenant_id = getattr(request.app.state, "tenant_id", None)
if not tenant_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "service token: workspace not initialized")
return CurrentUser(id="service", tenant_id=tenant_id, role="editor", email="service@forge.local")
# Per-tenant, role-scoped, revocable API keys (finding h). Recognizable by prefix and
# not a JWT, so resolve before the JWT decode. Identity is `apikey:<id>` in a specific
# tenant with the key's assigned role.
from forge.services.apikeys import ApiKeyService, looks_like_api_key
if looks_like_api_key(token):
async with SessionLocal() as s:
key = await ApiKeyService.resolve(s, token)
if key is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or revoked API key")
return CurrentUser(id=f"apikey:{key.id}", tenant_id=key.tenant_id, role=key.role,
email=f"apikey:{key.name}")
try:
claims = decode_token(token, expected_type="access")
except TokenError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {e}") from e
# Logout-all / password-change cutoff: reject an access token minted before the user's
# current revocation horizon even though it's otherwise still valid (finding d).
if tokens_revoked_after(claims):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session has been signed out")
async with SessionLocal() as s:
user = await AuthService.get_user(s, claims.get("sub", ""))
if user is None or user.status != "active":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account not found or disabled")
return CurrentUser(id=user.id, tenant_id=user.tenant_id, role=user.role, email=user.email)
if settings.auth_required:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
# Backward-compatible fallback: the seeded workspace owner.
tenant_id = getattr(request.app.state, "tenant_id", None)
if not tenant_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "no authenticated user")
return CurrentUser(id="system-dev", tenant_id=tenant_id, role="owner", email="you@forge.local", is_fallback=True)
def current_tenant_id(user: CurrentUser = Depends(get_current_user)) -> str:
# Bind the tenant for this request so the Postgres RLS GUC listener (forge.db.base) sets
# app.current_tenant on every transaction that follows in the route body. No-op on SQLite.
from forge.db.scoping import set_current_tenant
set_current_tenant(user.tenant_id)
return user.tenant_id
def _more_privileged(a: str, b: str) -> str:
"""Return whichever of two roles carries MORE privilege (owner > admin > editor > viewer)."""
return a if role_at_least(a, b) else b
async def effective_role(user: CurrentUser, request: Request) -> str:
"""The caller's role on the request's project: a per-project ProjectMember grant ELEVATES the
tenant-wide role (never demotes it), so this is additive (finding h). Falls back to the global
role when there is no project in the path, no membership row, or a non-user identity (service /
API key / dev fallback)."""
project_id = request.path_params.get("project_id")
if not project_id or user.is_fallback or user.id.startswith(("apikey:", "service")):
return user.role
from forge.models.entities import ProjectMember
async with SessionLocal() as s:
pm = (
await s.execute(
select(ProjectMember).where(
ProjectMember.tenant_id == user.tenant_id,
ProjectMember.project_id == project_id,
ProjectMember.user_id == user.id,
)
)
).scalar_one_or_none()
return user.role if pm is None else _more_privileged(user.role, pm.role)
def require_role(minimum: str):
"""Dependency factory: require the caller's EFFECTIVE role to be at least `minimum`
(owner > admin > editor > viewer). The effective role is the tenant-wide role, optionally
elevated by a per-project ProjectMember grant for the request's {project_id} (finding h).
Use on mutating/administrative routes."""
async def _dep(request: Request, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
role = await effective_role(user, request)
if not role_at_least(role, minimum):
raise HTTPException(status.HTTP_403_FORBIDDEN, f"requires role '{minimum}' or higher")
# Reflect the (possibly elevated) effective role back to the route body.
if role != user.role:
return CurrentUser(id=user.id, tenant_id=user.tenant_id, role=role,
email=user.email, is_fallback=user.is_fallback)
return user
return _dep
def client_ip(request: Request) -> str | None:
"""Client IP for per-IP rate limits / audit. Believes X-Forwarded-For only when the socket
peer is a configured reverse proxy (settings.trusted_proxies); see forge.util.clientip.
Shared with the audit middleware so the two resolvers can't drift."""
peer = request.client.host if request.client else None
return resolve_client_ip(peer, request.headers.get("x-forwarded-for"), settings.trusted_proxies)
# Header carrying ephemeral, per-run request context (a JSON object) that a server-side caller
# passes on a run's EXECUTION request (stream/resume). Its values are exposed to tools as
# {{ctx.<key>}} for on-behalf-of injection (e.g. a per-user session cookie / CSRF token) and
# are NEVER persisted or placed in the LLM prompt. Keep it small - it is a credential/context
# channel, not a data channel.
FORGE_CONTEXT_HEADER = "x-forge-context"
_MAX_RUN_CONTEXT_BYTES = 8192
def run_context(request: Request) -> dict | None:
"""Parse the `X-Forge-Context` header into a per-run context dict, or None if absent.
Rejects non-JSON / non-object / oversized payloads. `end_user` is stripped: run identity
is asserted via the run body / session token, not this header, so it can't be spoofed here.
"""
raw = request.headers.get(FORGE_CONTEXT_HEADER)
if not raw:
return None
if len(raw) > _MAX_RUN_CONTEXT_BYTES:
raise HTTPException(413, f"{FORGE_CONTEXT_HEADER} header too large")
try:
data = json.loads(raw)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{FORGE_CONTEXT_HEADER} must be a valid JSON object") from e
if not isinstance(data, dict):
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{FORGE_CONTEXT_HEADER} must be a JSON object")
data.pop("end_user", None) # identity is not settable via this channel
return data or None
def get_run_service(request: Request) -> RunService:
return RunService(
checkpointer=getattr(request.app.state, "checkpointer", None),
store=getattr(request.app.state, "store", None),
)
def get_checkpointer(request: Request):
return getattr(request.app.state, "checkpointer", None)
+27
View File
@@ -0,0 +1,27 @@
"""Forge execution engine: JSON workflow definitions -> compiled LangGraph graphs.
The heart of the platform. Public surface:
- `registry` - Node Type Registry (`NodeSpec`, `Port`, `register`).
- `compile_workflow` - executable JSON -> `CompiledStateGraph`.
- `build_state_typeddict` - state schema dict -> runtime `TypedDict` with reducers.
- `build_middleware` - middleware stack list -> `list[AgentMiddleware]`.
- `resolve_model` - model ref string -> chat model (provider or fake).
- `CompileContext` - per-compile dependencies (tenant, checkpointer, tools, ...).
"""
from forge.engine.context import CompileContext
from forge.engine.models import resolve_model
from forge.engine.registry import NODE_REGISTRY, NodeSpec, Port, io_compatible, register
from forge.engine.state import build_state_typeddict
__all__ = [
"CompileContext",
"resolve_model",
"NODE_REGISTRY",
"NodeSpec",
"Port",
"io_compatible",
"register",
"build_state_typeddict",
]
+188
View File
@@ -0,0 +1,188 @@
"""The workflow compiler (Doc 2 §6): executable JSON -> `CompiledStateGraph`.
Topologically agnostic - it trusts the validator (schemas/workflow.json + extra
rules) to have already rejected bad definitions. Routing:
- `router` nodes route via their own `config.cases`/`default` (conditional edges);
their labeled out-edges in `edges[]` are ignored.
- edges with `branches` (value->target) become conditional edges keyed by an
optional `condition` expression.
- `end` nodes are wired to END; plain edges are added as-is.
"""
from __future__ import annotations
import logging
from typing import Any
from langgraph.graph import END, START, StateGraph
import forge.nodes # noqa: F401 (import registers all built-in node types)
from forge.engine.context import CompileContext
from forge.engine.expressions import ExpressionError, eval_expression
from forge.engine.registry import get_spec
from forge.engine.state import build_state_typeddict
from forge.nodes.flow import (
make_fanout_path,
make_router_path,
resilient_fanout_child,
router_targets,
)
log = logging.getLogger("forge.compiler")
def _branch_path(condition: str | None, mapping: dict[str, str], source: str = "?"):
def _path(state: dict) -> Any:
if not condition:
# A branches-edge with no condition can only ever fall through to END. The
# validator now errors on this (audit F10c); if one still slips through, make the
# dead-end visible rather than silently ending the run (mirrors flow.py routers).
log.warning("edge from %r has branches but no condition; routing to END", source)
return END
try:
val = str(eval_expression(condition, dict(state or {})))
except ExpressionError as e:
# A failing branch expression silently routing to END is a debugging nightmare;
# log it (mirrors make_router_path in flow.py) so it's traceable (audit F8).
log.warning("edge %r branch condition %r failed: %s", source, condition, e)
return END
if val in mapping:
return mapping[val]
log.warning("edge %r branch value %r matched no branch; routing to END", source, val)
return END
return _path
SUBAGENT_HANDLE = "subagents"
def _subagent_spec_from_node(node: dict) -> dict:
"""Turn a specialist agent NODE's config into the subagent dict `build_subagents` expects
(name/description/system_prompt/tools/toolsets/model/middleware). Lets a deep_agent's
sub-agents be authored as full agent nodes on the canvas rather than inline JSON."""
cfg = node.get("config", {}) or {}
name = cfg.get("name") or node["id"]
spec: dict[str, Any] = {
"name": name,
# The supervisor reads this to decide when to call the sub-agent (like a tool description).
"description": cfg.get("description") or f"The {name} specialist agent.",
# deepagents requires every sub-agent to carry a system_prompt (it wraps it with a
# profile prompt), so always provide one - fall back to the description / a generic.
"system_prompt": cfg.get("system_prompt") or cfg.get("description") or f"You are the {name} specialist agent.",
}
for k in ("tools", "toolsets", "model", "middleware"):
if cfg.get(k):
spec[k] = cfg[k]
return spec
def compile_workflow(definition: dict, ctx: CompileContext):
"""Compile an executable workflow definition into a runnable LangGraph graph."""
state_schema = build_state_typeddict(definition.get("state", {}))
builder = StateGraph(state_schema)
nodes = definition["nodes"]
node_by_id = {n["id"]: n for n in nodes}
# Sub-agent edges (source_handle == "subagents") wire a deep_agent to specialist agent nodes
# it can call as tools. Those child nodes are NOT standalone graph nodes: fold each child's
# config into the parent deep_agent's `subagents` (create_deep_agent picks them up in
# agent_factory) and skip the child + its edges below. This is the canvas supervisor pattern.
subagent_child_ids: set[str] = set()
subagent_by_parent: dict[str, list[str]] = {}
for e in definition.get("edges", []):
if e.get("source_handle") != SUBAGENT_HANDLE:
continue
parent, child = e["source"], e.get("target")
if child in node_by_id and (node_by_id.get(parent) or {}).get("type") == "deep_agent":
subagent_by_parent.setdefault(parent, []).append(child)
subagent_child_ids.add(child)
for parent_id, child_ids in subagent_by_parent.items():
parent = node_by_id[parent_id]
cfg = dict(parent.get("config") or {})
cfg["subagents"] = list(cfg.get("subagents") or []) + [
_subagent_spec_from_node(node_by_id[c]) for c in child_ids
]
parent["config"] = cfg
# A parallel_fanout dispatches one Send per item to its `child_node` (all run in one
# superstep). Map each such child id -> its fanout config so we can optionally harden the
# child against a single item's failure/timeout below (audit F2). The workflow-level
# error_policy "continue" is the opt-in for partial-failure isolation.
error_policy = definition.get("error_policy", "halt")
fanout_children: dict[str, dict] = {}
for n in nodes:
if n["type"] == "parallel_fanout":
fcfg = n.get("config", {}) or {}
if fcfg.get("child_node"):
fanout_children[fcfg["child_node"]] = fcfg
# 1) add every node from its registered factory (folded sub-agent children are not nodes)
for n in nodes:
if n["id"] in subagent_child_ids:
continue
spec = get_spec(n["type"])
node_fn = spec.factory(n.get("config", {}) or {}, ctx)
fcfg = fanout_children.get(n["id"])
if fcfg is not None:
# Isolate per-item errors when the workflow opts into continue-on-error OR the
# fanout sets on_item_error="skip"; bound each item with an optional per-item
# timeout. Default (halt / no timeout) leaves the child untouched (safe).
isolate = error_policy == "continue" or fcfg.get("on_item_error") == "skip"
timeout = fcfg.get("item_timeout_seconds")
if isolate or timeout:
node_fn = resilient_fanout_child(node_fn, timeout=timeout, isolate=isolate)
builder.add_node(n["id"], node_fn)
# 2) terminal markers -> END
for n in nodes:
if n["type"] == "end":
builder.add_edge(n["id"], END)
# 3) router nodes -> conditional edges from their own config
routed: set[str] = set()
for n in nodes:
if n["type"] == "router":
cfg = n.get("config", {}) or {}
targets = router_targets(cfg) or [END]
builder.add_conditional_edges(n["id"], make_router_path(cfg), targets)
routed.add(n["id"])
# 3b) parallel_fanout nodes -> Send-based map to their child_node (skip normal edges)
for n in nodes:
if n["type"] == "parallel_fanout":
cfg = n.get("config", {}) or {}
child = cfg.get("child_node")
if child:
builder.add_conditional_edges(n["id"], make_fanout_path(cfg), [child])
routed.add(n["id"])
# 4) explicit edges (skip self-routing router/fanout sources, sub-agent edges, and any edge
# touching a folded sub-agent child - the child is no longer a node in the graph)
for e in definition.get("edges", []):
src = e["source"]
if e.get("source_handle") == SUBAGENT_HANDLE:
continue
if src in routed:
continue
if src in subagent_child_ids or e.get("target") in subagent_child_ids:
continue
if e.get("branches"):
mapping = {str(k): v for k, v in e["branches"].items()}
# END must be in the target set: _branch_path falls through to END on a failed /
# unmatched condition, and without END listed LangGraph raises KeyError('__end__')
# at runtime instead of the intended (now-logged) graceful end (audit F8).
targets = sorted(set(mapping.values()) | {END})
builder.add_conditional_edges(
src, _branch_path(e.get("condition"), mapping, src), targets
)
else:
tgt = e["target"]
builder.add_edge(src, END if tgt in ("END", "__end__") else tgt)
# 5) entry
builder.add_edge(START, definition["entry_node"])
return builder.compile(checkpointer=ctx.checkpointer, store=ctx.store)
+143
View File
@@ -0,0 +1,143 @@
"""CompileContext - the per-compile dependency bundle (Doc 2 §6).
Carries everything `NodeSpec.factory` / `MW_BUILDERS` need: tenant scoping, the
checkpointer + store, the tracer callback, the materialized tool registry, the auth
resolver, the sandbox, and model-provider credential bindings. Kept dependency-light
(plain dataclass with optionals) so the engine core is unit-testable in isolation.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
@dataclass
class CompileContext:
tenant_id: str
project_id: str
# LangGraph durability + long-term memory.
checkpointer: Any = None
store: Any = None
# Tracing callback handler attached to every astream/ainvoke.
tracer: Any = None
# Materialized tools: tool_id -> StructuredTool (built by tools.materialize).
tool_registry: dict[str, Any] = field(default_factory=dict)
# tool_id -> {"kind", "config", "tool"} so the tool_call node can invoke directly.
tool_specs: dict[str, dict] = field(default_factory=dict)
# LLM tool name (the underscore identifier the model calls) -> human-readable label
# shown in streaming/chat activity. Populated for user tools (config.display_name) and
# UI components (their title), each falling back to the identifier when unset. The model
# never sees this - it only relabels tool_calls in the stream for end-user surfaces.
tool_display_names: dict[str, str] = field(default_factory=dict)
# MCP server id -> list of native LangChain tools (the server's enabled tools),
# pre-loaded by the runtime assembler so the sync agent factory can attach them.
mcp_tools_by_client: dict[str, list] = field(default_factory=dict)
# Materialized UI components (component_id -> widget StructuredTool); attached to an
# agent via config["components"], the same way tools are (Feature 2 - generative UI).
component_registry: dict[str, Any] = field(default_factory=dict)
# Tool-set membership (tool_set_id -> [tool_id, ...]) for the project, populated by the
# runtime assembler. Lets an agent be granted a whole set via config.toolsets and have it
# resolve to the set's member tools at compile time (see resolve_tool_ids).
toolset_members: dict[str, list[str]] = field(default_factory=dict)
# Cross-cutting services.
auth_resolver: Any = None
sandbox: Any = None
# SSRF egress policy (project override of the global allow/deny + private-range
# block), applied to every outbound HTTP call a workflow makes (tools, webhooks,
# web_fetch). Set by the runtime assembler from project.config.egress.
egress_policy: Any = None
# Model config.
default_model: str | None = None
provider_credentials: dict[str, str] = field(default_factory=dict)
# The end user this run acts for (identity, Feature 3). Generic app-defined shape
# ({id, roles?, attributes?, entitlements?, …}); surfaced to agent prompts (awareness)
# and tool templating ({{ctx.end_user…}} / on-behalf-of calls). None = anonymous.
end_user: dict | None = None
# Ephemeral per-run request context (Feature: per-run context injection). Values a
# server-side caller passes on the run's EXECUTION request (stream/resume, via the
# `X-Forge-Context` header) for tools to inject into outbound calls as {{ctx.<key>}} -
# e.g. a per-user session cookie / CSRF token when acting on the caller's behalf. UNLIKE
# end_user this is NEVER persisted (not on the thread/run/checkpointer/trace) and NEVER
# placed in the LLM prompt or an LLM-visible tool arg; it reaches only the tool's outbound
# HTTP request and the auth resolver. Put per-request secrets HERE, not in end_user (which
# is embedded in the prompt and stored on the thread).
run_context: dict = field(default_factory=dict)
# Project-level default middleware, prepended to every agent stack (Doc 2 §8).
project_default_mw: list[dict] = field(default_factory=list)
# Saved agent presets (agent_id -> config), so an agent node can mirror one by
# `agent_ref` and pick up edits made in the Agents tab without re-saving the workflow.
agent_presets: dict[str, dict] = field(default_factory=dict)
# Project workflows' executables (id -> definition) so a `subworkflow` node can
# compile a referenced workflow as a nested graph. `compiling` tracks in-progress
# ids to break recursion cycles.
workflows: dict[str, dict] = field(default_factory=dict)
compiling: set = field(default_factory=set)
def tools_for(self, ids: Sequence[str]) -> list[Any]:
"""Resolve tool ids to materialized tools, skipping unknown ids.
Unknown ids are tolerated at compile time and surfaced by the validator
instead, so a partially-wired draft still compiles for preview.
"""
out = []
for i in ids or []:
tool = self.tool_registry.get(i)
if tool is not None:
out.append(tool)
return out
def resolve_tool_ids(self, tool_ids: Sequence[str] | None, set_ids: Sequence[str] | None = None) -> list[str]:
"""Combine explicit tool ids with the members of any referenced tool sets into one
order-stable, de-duplicated id list. Unknown set ids contribute nothing (tolerant,
like tools_for), so an agent can be granted individual tools AND whole sets at once."""
seen: set[str] = set()
out: list[str] = []
for tid in tool_ids or []:
if tid not in seen:
seen.add(tid)
out.append(tid)
for sid in set_ids or []:
for tid in self.toolset_members.get(sid, []):
if tid not in seen:
seen.add(tid)
out.append(tid)
return out
def components_for(self, ids: Sequence[str]) -> list[Any]:
"""Resolve component ids to materialized widget-tools, skipping unknown ids
(a deleted component just drops out, like tools_for)."""
out = []
for i in ids or []:
tool = self.component_registry.get(i)
if tool is not None:
out.append(tool)
return out
def has_entitlements(self, required) -> bool:
"""True if the run's end_user holds ALL of `required` (matched against roles
entitlements). Empty/absent requirement → allowed, anonymous user → denied. The
server-side gate for tools that declare `required_entitlements` (Feature 3b)."""
req = [r for r in (required or []) if r]
if not req:
return True
eu = self.end_user or {}
have = set(eu.get("entitlements") or []) | set(eu.get("roles") or [])
return all(r in have for r in req)
def sandbox_backend_for(self, config: dict) -> Any:
"""Deep-agent sandbox backend from a node's sandbox config. (Phase 3+.)"""
return self.sandbox
+62
View File
@@ -0,0 +1,62 @@
"""Safe expression evaluation over run state/context (Doc 4 `Expression`).
Forge uses TWO expression languages, by role - keep them straight:
- **This one (RestrictedPython)** is for BOOLEAN/VALUE DECISIONS over state: `router`
`expression`/`cases`, `loop` conditions, `dynamic_model_by_state`, `tenant_budget`.
State keys are bare names, so `intent == 'billing'` and `len(messages) > 10` work.
- **JMESPath** is for DATA EXTRACTION/RESHAPING: `transform` node, tool response
`projection_jmespath`, and `tool_call` `input_mapping`. It selects/reshapes JSON; it
does not evaluate Python comparisons.
Rule of thumb: "which branch?" → this module; "pull/reshape these fields" → JMESPath.
Expressions here are sandboxed with RestrictedPython: no imports, no attribute escapes,
only a small set of safe builtins, plus explicit `state` / `context` dicts.
"""
from __future__ import annotations
from typing import Any
from RestrictedPython import compile_restricted, safe_builtins
from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
_SAFE_NAMES: dict[str, Any] = {
"len": len, "min": min, "max": max, "sum": sum, "abs": abs, "round": round,
"any": any, "all": all, "sorted": sorted, "str": str, "int": int, "float": float,
"bool": bool, "list": list, "dict": dict, "set": set, "tuple": tuple,
"True": True, "False": False, "None": None,
}
class ExpressionError(ValueError):
"""Raised when an expression fails to compile or evaluate."""
def eval_expression(expr: str, state: dict | None = None, context: dict | None = None) -> Any:
state = dict(state or {})
context = dict(context or {})
try:
code = compile_restricted(expr, "<forge-expression>", "eval")
except SyntaxError as e:
raise ExpressionError(f"Invalid expression {expr!r}: {e}") from e
env: dict[str, Any] = {
**state, # state keys as bare names - spread FIRST so a state key can't shadow a guard
# Reserved/guard keys are set AFTER **state so the sandbox guards and safe builtins
# always win (a state key named e.g. "__builtins__" or "_getitem_" can't override them).
"__builtins__": safe_builtins,
"_getitem_": default_guarded_getitem,
"_getiter_": default_guarded_getiter,
**_SAFE_NAMES,
"state": state,
"context": context,
"ctx": context,
}
try:
return eval(code, env, {}) # noqa: S307 - sandboxed by RestrictedPython
except Exception as e: # noqa: BLE001 - surface any eval failure as ExpressionError
raise ExpressionError(f"Failed to evaluate {expr!r}: {type(e).__name__}: {e}") from e
def eval_truthy(expr: str, state: dict | None = None, context: dict | None = None) -> bool:
return bool(eval_expression(expr, state, context))
@@ -0,0 +1,601 @@
"""Middleware-Stack Compiler (Doc 2 §8) - the engine of "limitless customization".
An agent node's `middleware: [{type, config, enabled}]` list compiles to a concrete
`list[AgentMiddleware]`. Prebuilt builders wrap LangChain's catalog (signatures
validated against langchain 1.3.4). The custom/advanced builders generate middleware
from declarative rules so non-coders get power without writing code.
Add a middleware = add a `MW_BUILDERS` entry (+ a `config_schemas` entry in
schemas/middleware.json + a `category-map` entry). It then appears everywhere.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from typing import Annotated, Any, NotRequired
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ClearToolUsesEdit,
ContextEditingMiddleware,
HumanInTheLoopMiddleware,
LLMToolEmulator,
LLMToolSelectorMiddleware,
ModelCallLimitMiddleware,
ModelFallbackMiddleware,
ModelRetryMiddleware,
PIIMiddleware,
SummarizationMiddleware,
TodoListMiddleware,
ToolCallLimitMiddleware,
ToolRetryMiddleware,
hook_config,
)
from langchain.agents.middleware.types import PrivateStateAttr
from langgraph.channels.untracked_value import UntrackedValue
from forge.engine.context import CompileContext
from forge.engine.expressions import eval_truthy
from forge.engine.models import resolve_model
log = logging.getLogger("forge.middleware")
Builder = Callable[[dict, CompileContext], AgentMiddleware]
# --- helpers ---------------------------------------------------------------
def _ctxsize(v: Any) -> Any:
"""JSON ContextSize (list) -> tuple; list-of-ContextSize -> list[tuple]."""
if v is None:
return None
if isinstance(v, list) and v and isinstance(v[0], list):
return [tuple(x) for x in v]
if isinstance(v, (list, tuple)):
return tuple(v)
return v
def _pick(c: dict, keys: list[str]) -> dict:
return {k: c[k] for k in keys if k in c and c[k] is not None}
def _context_matches(expose_when: dict, ctx_data: dict) -> bool:
"""All keys in expose_when must match (value-in-list or equality)."""
for k, want in (expose_when or {}).items():
got = ctx_data.get(k)
if isinstance(want, list):
if got not in want:
return False
elif got != want:
return False
return True
def _msg_text(msg: Any) -> str:
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
if isinstance(content, list):
return " ".join(
(b.get("text", "") if isinstance(b, dict) else str(b)) for b in content
)
return content or ""
# --- prebuilt builders (signatures validated against langchain 1.3.4) ------
def _summarization(c: dict, ctx: CompileContext) -> AgentMiddleware:
kw: dict[str, Any] = {}
if c.get("trigger") is not None:
kw["trigger"] = _ctxsize(c["trigger"])
if c.get("keep") is not None:
kw["keep"] = _ctxsize(c["keep"])
if c.get("summary_prompt"):
kw["summary_prompt"] = c["summary_prompt"]
return SummarizationMiddleware(model=resolve_model(c.get("model"), ctx), **kw)
def _model_fallback(c: dict, ctx: CompileContext) -> AgentMiddleware:
models = [resolve_model(m, ctx) for m in c["models"]]
return ModelFallbackMiddleware(models[0], *models[1:])
def _pii(c: dict, ctx: CompileContext) -> AgentMiddleware:
return PIIMiddleware(
c["pii_type"],
strategy=c.get("strategy", "redact"),
detector=c.get("detector"),
apply_to_input=c.get("apply_to_input", True),
apply_to_output=c.get("apply_to_output", False),
apply_to_tool_results=c.get("apply_to_tool_results", False),
)
def _llm_tool_selector(c: dict, ctx: CompileContext) -> AgentMiddleware:
kw = _pick(c, ["max_tools", "always_include"])
return LLMToolSelectorMiddleware(model=resolve_model(c.get("model"), ctx), **kw)
# retry_on string names (from the JSON config) -> real exception types the lib expects.
# ("http_error"/"httpx"/"http" resolve lazily to httpx.HTTPError in _retry_exceptions.)
_RETRY_EXC_MAP: dict[str, type[BaseException]] = {
"exception": Exception,
"timeout": TimeoutError,
"timeout_error": TimeoutError,
"connection": ConnectionError,
"connection_error": ConnectionError,
"value_error": ValueError,
"key_error": KeyError,
"runtime_error": RuntimeError,
}
def _retry_exceptions(names: list[str]) -> tuple[type[BaseException], ...]:
out: list[type[BaseException]] = []
for n in names or []:
key = str(n).strip().lower()
if key in ("http_error", "httpx", "http"):
import httpx
out.append(httpx.HTTPError)
continue
exc = _RETRY_EXC_MAP.get(key)
if exc is not None:
out.append(exc)
return tuple(out)
def _tool_retry(c: dict, ctx: CompileContext) -> AgentMiddleware:
kw = _pick(c, ["max_retries", "tools", "on_failure", "backoff_factor", "initial_delay", "max_delay", "jitter"])
retry_on = _retry_exceptions(c.get("retry_on") or [])
if retry_on:
kw["retry_on"] = retry_on
return ToolRetryMiddleware(**kw)
def _model_retry(c: dict, ctx: CompileContext) -> AgentMiddleware:
kw = _pick(c, ["max_retries", "on_failure", "backoff_factor", "initial_delay", "max_delay", "jitter"])
# Pass retry_on through like _tool_retry does - it was dropped before, so "retry only on
# timeouts/http errors" configs silently retried on ANY exception (audit F5).
retry_on = _retry_exceptions(c.get("retry_on") or [])
if retry_on:
kw["retry_on"] = retry_on
return ModelRetryMiddleware(**kw)
def _tool_emulator(c: dict, ctx: CompileContext) -> AgentMiddleware:
kw: dict[str, Any] = {}
if c.get("tools") is not None:
kw["tools"] = c["tools"]
if c.get("model"):
kw["model"] = resolve_model(c["model"], ctx)
return LLMToolEmulator(**kw)
def _context_editing(c: dict, ctx: CompileContext) -> AgentMiddleware:
edits = [ClearToolUsesEdit(**e) for e in c.get("edits", [])] or [ClearToolUsesEdit()]
return ContextEditingMiddleware(edits=edits)
def _anthropic_prompt_caching(c: dict, ctx: CompileContext) -> AgentMiddleware:
# Provider-specific: lives in langchain-anthropic (install extra: providers).
try:
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
except ImportError as e: # pragma: no cover - depends on optional extra
raise ImportError(
"anthropic_prompt_caching needs `langchain-anthropic` "
"(pip install -e '.[providers]')."
) from e
return AnthropicPromptCachingMiddleware(**_pick(c, ["ttl"]))
def _openai_moderation(c: dict, ctx: CompileContext) -> AgentMiddleware:
try:
from langchain_openai.middleware import OpenAIModerationMiddleware
except ImportError as e: # pragma: no cover - depends on optional extra
raise ImportError(
"openai_moderation needs `langchain-openai` (pip install -e '.[providers]')."
) from e
# langchain-openai (>=1.3) renamed the toggles apply_to_* -> check_* (and added
# check_tool_results / exit_behavior / model / violation_message). Map from our schema's
# apply_to_* names (shared with `pii`, and already present in saved configs) to the
# library's current kwargs so enabling this middleware doesn't fail to compile.
kw: dict[str, Any] = {}
for schema_key, lib_key in (
("apply_to_input", "check_input"),
("apply_to_output", "check_output"),
("apply_to_tool_results", "check_tool_results"),
):
if c.get(schema_key) is not None:
kw[lib_key] = c[schema_key]
# Pass-through kwargs the library accepts as-is (available via Advanced JSON).
for k in ("exit_behavior", "model", "violation_message"):
if c.get(k) is not None:
kw[k] = c[k]
return OpenAIModerationMiddleware(**kw)
# --- custom / advanced builders (declarative rules -> hooks) ---------------
class _DynamicModelByStateMiddleware(AgentMiddleware):
"""Switch the model at runtime by a state expression. Implemented as a class with BOTH the
sync and async wrap hooks: the previous `@wrap_model_call`-on-a-sync-function version only
provided the sync path, so it raised NotImplementedError under astream/ainvoke (the runtime
path) - it never actually worked live. Wiring the agent-node `dynamic_model` field depends
on this being async-safe (audit F9)."""
def __init__(self, rules: list[dict], default: Any, ctx: CompileContext):
super().__init__()
self._rules = rules or []
self._default = default
self._ctx = ctx
def _apply(self, request):
chosen = self._default
for r in self._rules:
try:
if eval_truthy(r["when"], dict(request.state or {})):
chosen = r["use"]
break
except Exception: # noqa: BLE001 - a bad rule shouldn't kill the run
continue
return request.override(model=resolve_model(chosen, self._ctx)) if chosen else request
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
return handler(self._apply(request))
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
return await handler(self._apply(request))
def _dynamic_model_by_state(c: dict, ctx: CompileContext) -> AgentMiddleware:
return _DynamicModelByStateMiddleware(c.get("rules", []), c.get("default"), ctx)
class _ToolFilterByContextMiddleware(AgentMiddleware):
"""Show/hide tools at runtime based on the run's context (auth state, role, flags). Same
sync+async fix as _DynamicModelByStateMiddleware - it was sync-only and thus a no-op-then-
crash under async execution."""
def __init__(self, expose_when: dict, gated: set[str]):
super().__init__()
self._expose_when = expose_when
self._gated = gated
def _apply(self, request):
rt_ctx = getattr(getattr(request, "runtime", None), "context", None) or {}
allowed = _context_matches(self._expose_when, rt_ctx if isinstance(rt_ctx, dict) else {})
if not allowed and self._gated:
kept = [t for t in (request.tools or []) if getattr(t, "name", None) not in self._gated]
return request.override(tools=kept)
return request
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
return handler(self._apply(request))
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
return await handler(self._apply(request))
def _tool_filter_by_context(c: dict, ctx: CompileContext) -> AgentMiddleware:
return _ToolFilterByContextMiddleware(c.get("expose_when", {}), set(c.get("tools", [])))
_GUARDRAIL_REDACTION = "[redacted]"
class _GuardrailRegexMiddleware(AgentMiddleware):
"""Regex content guardrail honoring `apply_to` (input/output/both) and all three actions
(block/redact/flag) - previously only the OUTPUT was scanned and only `block` did anything;
`apply_to`, `redact` and `flag` were silently ignored (audit F4).
- block: replace the offending message with a fixed notice (existing behavior kept).
- redact: mask each matched span with `[redacted]`, leaving the rest of the message intact.
- flag: keep the content but tag `additional_kwargs['guardrail_flagged']` and log it.
Scanning input happens in before_model (so redaction is applied before the model sees it);
scanning output happens in after_model (the model's reply)."""
def __init__(self, patterns: list[str], on_match: str = "block", apply_to: str = "output"):
super().__init__()
self._patterns = [re.compile(p) for p in patterns]
self._mode = on_match
self._apply_to = apply_to
def _rewrite(self, msg: Any):
"""Return a replacement message if a pattern matches under the mode, else None."""
text = _msg_text(msg)
if not self._patterns or not any(p.search(text) for p in self._patterns):
return None
from langchain_core.messages import AIMessage, HumanMessage
is_ai = getattr(msg, "type", None) == "ai"
make = AIMessage if is_ai else HumanMessage
if self._mode == "block":
return make(content="[blocked by content guardrail]")
if self._mode == "redact":
redacted = text
for p in self._patterns:
redacted = p.sub(_GUARDRAIL_REDACTION, redacted)
return make(content=redacted)
# flag: keep content, mark it for review (visible in the transcript / trace) + log.
ak = dict(getattr(msg, "additional_kwargs", {}) or {})
ak["guardrail_flagged"] = True
log.warning("guardrail_regex flagged a message (a pattern matched)")
return make(content=text, additional_kwargs=ak)
def _scan(self, state: dict, which: str):
from langchain_core.messages import RemoveMessage
msgs = state.get("messages") or []
want = ("human", "user") if which == "input" else ("ai",)
target = next((m for m in reversed(msgs) if getattr(m, "type", None) in want), None)
if target is None:
return None
repl = self._rewrite(target)
if repl is None:
return None
tid = getattr(target, "id", None)
return {"messages": [RemoveMessage(id=tid), repl] if tid else [repl]}
def before_model(self, state, runtime=None): # type: ignore[no-untyped-def]
if self._apply_to in ("input", "both"):
return self._scan(state, "input")
return None
def after_model(self, state, runtime=None): # type: ignore[no-untyped-def]
if self._apply_to in ("output", "both"):
return self._scan(state, "output")
return None
def _guardrail_regex(c: dict, ctx: CompileContext) -> AgentMiddleware:
return _GuardrailRegexMiddleware(
patterns=c.get("patterns", []),
on_match=c.get("on_match", "block"),
apply_to=c.get("apply_to", "output"),
)
class _TenantBudgetState(AgentState):
# Run-scoped token tally: UntrackedValue channels are NOT checkpointed, so this resets at
# the start of every run (invocation) - giving true per-RUN scoping, unlike the old code
# that summed usage over the whole persisted thread (audit F3). PrivateStateAttr keeps it
# out of the agent's input/output schema so it never leaks to the workflow state.
_forge_run_tokens: NotRequired[Annotated[int, UntrackedValue, PrivateStateAttr]]
# Thread-scoped USD tally: a normal (checkpointed) channel, so it accumulates across the
# runs of a thread - matching `max_usd_per_thread`.
_forge_thread_cost_usd: NotRequired[Annotated[float, PrivateStateAttr]]
class _TenantBudgetMiddleware(AgentMiddleware):
"""Stop the run/thread when accumulated tokens or USD exceed a cap.
- `max_tokens_per_run` is now scoped to the current RUN (was: whole persisted thread).
- `max_usd_per_thread` is implemented via span-style pricing (forge.tracing.pricing.price)
accumulated across the thread - previously the field was accepted and ignored (F3).
Cost is only priced when a USD cap is set (keeps the token-only path free of lookups)."""
state_schema = _TenantBudgetState # type: ignore[assignment]
def __init__(self, max_tokens_per_run: int | None, max_usd_per_thread: float | None, on_exceed: str = "end"):
super().__init__()
self._max_tokens = max_tokens_per_run
self._max_usd = max_usd_per_thread
self._on_exceed = on_exceed
def _exceeded(self, reason: str):
if self._on_exceed == "error":
raise RuntimeError(f"Tenant budget exceeded: {reason}")
from langchain_core.messages import AIMessage
return {"jump_to": "end", "messages": [AIMessage(content=f"[budget] run stopped: {reason}")]}
@hook_config(can_jump_to=["end"])
def before_model(self, state, runtime=None): # type: ignore[no-untyped-def]
run_tokens = state.get("_forge_run_tokens", 0) or 0
thread_cost = state.get("_forge_thread_cost_usd", 0.0) or 0.0
if self._max_tokens and run_tokens >= self._max_tokens:
return self._exceeded(f"{run_tokens} >= {self._max_tokens} tokens (this run)")
if self._max_usd and thread_cost >= self._max_usd:
return self._exceeded(f"${thread_cost:.4f} >= ${self._max_usd} (this thread)")
return None
def after_model(self, state, runtime=None): # type: ignore[no-untyped-def]
msgs = state.get("messages") or []
if not msgs:
return None
last = msgs[-1]
usage = getattr(last, "usage_metadata", None) or (
last.get("usage_metadata") if isinstance(last, dict) else None
)
if not usage:
return None
in_tok = usage.get("input_tokens", 0) or 0
out_tok = usage.get("output_tokens", 0) or 0
total = usage.get("total_tokens", in_tok + out_tok) or 0
update: dict[str, Any] = {"_forge_run_tokens": (state.get("_forge_run_tokens", 0) or 0) + total}
if self._max_usd:
from forge.tracing.pricing import price
rm = getattr(last, "response_metadata", None) or {}
model_name = rm.get("model_name") or rm.get("model")
details = usage.get("input_token_details") or {}
cost = price(
model_name, in_tok, out_tok,
cache_read_tokens=details.get("cache_read", 0) or 0,
cache_creation_tokens=details.get("cache_creation", 0) or 0,
)
update["_forge_thread_cost_usd"] = (state.get("_forge_thread_cost_usd", 0.0) or 0.0) + cost
return update
def _tenant_budget(c: dict, ctx: CompileContext) -> AgentMiddleware:
return _TenantBudgetMiddleware(
max_tokens_per_run=c.get("max_tokens_per_run"),
max_usd_per_thread=c.get("max_usd_per_thread"),
on_exceed=c.get("on_exceed", "end"),
)
def _cache_answer_text(response: Any) -> str | None:
"""Extract a cacheable FINAL-answer string from a model response, or None. Skips a
tool-calling turn (not a final answer) and empty/structured responses so we never cache
a mid-loop step or a blank reply."""
from langchain_core.messages import AIMessage
msg: Any = None
if isinstance(response, AIMessage):
msg = response
else:
result = getattr(response, "result", None)
if result is None: # ExtendedModelResponse wraps the ModelResponse
mr = getattr(response, "model_response", None)
result = getattr(mr, "result", None)
if result:
msg = next(
(m for m in result if getattr(m, "type", None) in ("ai", "AIMessageChunk")),
result[0],
)
if msg is None or getattr(msg, "tool_calls", None):
return None
text = _msg_text(msg).strip()
return text or None
class _SemanticCacheMiddleware(AgentMiddleware):
"""Answer cache keyed by question MEANING (vector similarity), wiring the built-but-unused
SemanticCacheService into the agent loop (audit A). On the FIRST model call of a fresh user
turn it looks the question up; a hit SHORT-CIRCUITS the whole model/tool loop with the cached
answer (returned as an AIMessage, which ends the ReAct loop). A fresh non-tool answer is stored
after. Gated per agent (only attached when the middleware is configured on the node).
Async-only: the embedder + cache store are async, so awrap_model_call does the work; the sync
wrap_model_call degrades to a pass-through (no caching) rather than crash - Forge's runtime
always drives agents with astream/ainvoke. Every cache call is fully guarded: a cache failure
(embedder cold, Chroma error) must never break a run."""
def __init__(self, tenant_id: str, project_id: str, *, threshold: float, ttl: int,
scope: str, min_chars: int):
super().__init__()
self._tenant = tenant_id
self._project = project_id
self._threshold = threshold
self._ttl = ttl
self._scope = scope
self._min = min_chars
def _question(self, request) -> str | None:
"""The user's question for THIS turn: the last message must be a human message (i.e.
the model is about to answer a fresh question, not continue a tool round). Returns None
mid-loop so we neither look up nor store a partial/tool step."""
msgs = getattr(request, "messages", None) or []
if not msgs:
return None
last = msgs[-1]
if getattr(last, "type", None) != "human":
return None
return _msg_text(last).strip() or None
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
return handler(request)
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
from forge.db.base import SessionLocal
from forge.services.semantic_cache import SemanticCacheService
question = self._question(request)
cacheable = bool(question) and len(question) >= self._min
if cacheable:
try:
async with SessionLocal() as s:
cached = await SemanticCacheService.lookup(
s, self._tenant, self._project, question,
scope=self._scope, threshold=self._threshold, ttl=self._ttl,
)
except Exception: # noqa: BLE001 - a cache lookup must never break the run
log.debug("semantic_cache lookup failed", exc_info=True)
cached = None
if cached is not None:
from langchain_core.messages import AIMessage
return AIMessage(content=cached)
response = await handler(request)
if cacheable:
answer = _cache_answer_text(response)
if answer:
try:
async with SessionLocal() as s:
await SemanticCacheService.store(
s, self._tenant, self._project, question, answer, scope=self._scope,
)
except Exception: # noqa: BLE001 - a cache write must never break the run
log.debug("semantic_cache store failed", exc_info=True)
return response
def _semantic_cache(c: dict, ctx: CompileContext) -> AgentMiddleware:
from forge.services.semantic_cache import CACHE_DEFAULT_THRESHOLD, CACHE_DEFAULT_TTL_SECONDS
return _SemanticCacheMiddleware(
ctx.tenant_id, ctx.project_id,
threshold=float(c.get("threshold", CACHE_DEFAULT_THRESHOLD)),
ttl=int(c.get("ttl", CACHE_DEFAULT_TTL_SECONDS)),
scope=str(c.get("scope") or "default"),
min_chars=int(c.get("min_question_chars", 8)),
)
MW_BUILDERS: dict[str, Builder] = {
"summarization": _summarization,
"human_in_the_loop": lambda c, ctx: HumanInTheLoopMiddleware(interrupt_on=c["interrupt_on"]),
"model_call_limit": lambda c, ctx: ModelCallLimitMiddleware(
**_pick(c, ["thread_limit", "run_limit", "exit_behavior"])
),
"tool_call_limit": lambda c, ctx: ToolCallLimitMiddleware(
**_pick(c, ["tool_name", "thread_limit", "run_limit", "exit_behavior"])
),
"model_fallback": _model_fallback,
"pii": _pii,
"todo": lambda c, ctx: TodoListMiddleware(**_pick(c, ["system_prompt", "tool_description"])),
"llm_tool_selector": _llm_tool_selector,
"tool_retry": _tool_retry,
"model_retry": _model_retry,
"tool_emulator": _tool_emulator,
"context_editing": _context_editing,
"anthropic_prompt_caching": _anthropic_prompt_caching,
"openai_moderation": _openai_moderation,
# custom / advanced
# (request_signing was removed: it was a no-op stub - auth injection already
# happens inside materialized REST tools via the AuthResolver.)
"dynamic_model_by_state": _dynamic_model_by_state,
"tool_filter_by_context": _tool_filter_by_context,
"guardrail_regex": _guardrail_regex,
"tenant_budget": _tenant_budget,
"semantic_cache": _semantic_cache,
}
def build_middleware(stack: list[dict] | None, ctx: CompileContext) -> list[AgentMiddleware]:
"""Compile a middleware stack list into concrete middleware instances.
Disabled entries are skipped. Unknown types raise (caught upstream by the
validator, which reports them as field-level errors before compile).
"""
out: list[AgentMiddleware] = []
for m in stack or []:
if m.get("enabled", True) is False:
continue
mtype = m.get("type")
builder = MW_BUILDERS.get(mtype)
if builder is None:
raise ValueError(f"Unknown middleware type: {mtype!r}")
out.append(builder(m.get("config") or {}, ctx))
return out
+130
View File
@@ -0,0 +1,130 @@
"""Resolve a model-ref string to a chat model.
Doc 2 §9 `resolve_model`: parse the provider-prefixed id; native packages for the
big three, gateways for the rest. A `fake:` scheme returns an offline model so the
engine, tests, and the playground "dry run" work with no API keys or network.
"""
from __future__ import annotations
import itertools
from typing import TYPE_CHECKING, Any
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage
from forge.config import settings
if TYPE_CHECKING:
from forge.engine.context import CompileContext
# Sensible per-provider default model, used when a project has a provider key
# configured but no explicit `default_model`. Keeps an agent node with no model
# from silently degrading to the offline `fake:` model. Ordered by preference.
_PROVIDER_DEFAULT_MODEL: dict[str, str] = {
"openai": "openai:gpt-4.1-mini",
"anthropic": "anthropic:claude-sonnet-4-6",
"google_genai": "google_genai:gemini-2.5-flash",
"google": "google_genai:gemini-2.5-flash",
}
def default_model_for_credentials(creds: dict | None) -> str | None:
"""Pick a real model ref for a project that has provider keys but no explicit
default_model. Returns None when no known provider key is present (caller then
falls back to the global offline default)."""
for provider in ("openai", "anthropic", "google_genai", "google"):
if provider in (creds or {}):
return _PROVIDER_DEFAULT_MODEL[provider]
return None
# Cheapest capable model per provider - for high-volume, low-stakes calls like intent
# classification, where a frontier model is wasted spend.
_PROVIDER_CHEAP_MODEL: dict[str, str] = {
"openai": "openai:gpt-4.1-nano",
"anthropic": "anthropic:claude-haiku-4-5",
"google_genai": "google_genai:gemini-2.5-flash",
"google": "google_genai:gemini-2.5-flash",
}
def cheap_model_for_credentials(creds: dict | None) -> str | None:
"""The cheapest model for a project's configured provider (classifier default)."""
for provider in ("openai", "anthropic", "google_genai", "google"):
if provider in (creds or {}):
return _PROVIDER_CHEAP_MODEL[provider]
return None
def make_fake_model(reply: str | None = None) -> BaseChatModel:
"""An offline chat model that returns a fixed final answer on every call.
No tool calls => any ReAct loop terminates immediately. Uses a cycled iterator
so repeated invocations never exhaust. `bind_tools` is a no-op so tool-bound
agents still run offline (tools are bound but never invoked by this model).
"""
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
class _ToolTolerantFake(GenericFakeChatModel):
def bind_tools(self, tools=None, **kwargs): # type: ignore[override]
return self
text = reply or "Hello from Forge's offline model. Configure a real provider model to go live."
return _ToolTolerantFake(messages=itertools.cycle([AIMessage(content=text)]))
def resolve_model(
model_ref: str | BaseChatModel | None,
ctx: CompileContext | None = None,
params: dict[str, Any] | None = None,
) -> BaseChatModel:
# Already a constructed model (e.g. injected by a test or middleware).
if isinstance(model_ref, BaseChatModel):
return model_ref
if not model_ref:
model_ref = (getattr(ctx, "default_model", None) if ctx else None) or settings.default_model
if isinstance(model_ref, str) and model_ref.startswith("fake"):
# "fake" or "fake:<text>"
_, _, reply = model_ref.partition(":")
return make_fake_model(reply or None)
# Real provider / gateway. init_chat_model parses "provider:model" and binds the
# right integration package; raises a clear ImportError if it isn't installed.
from langchain.chat_models import init_chat_model
clean = {k: v for k, v in (params or {}).items() if v is not None}
# Inject the project's provider API key (resolved from a secret by the runtime
# assembler into ctx.provider_credentials). Falls back to the provider's env var.
provider = model_ref.split(":", 1)[0] if ":" in model_ref else None
creds = getattr(ctx, "provider_credentials", None) or {}
key = creds.get(provider) if provider else None
if key:
param = "google_api_key" if provider in ("google_genai", "google") else "api_key"
clean.setdefault(param, key)
# OpenAI streams tokens without usage by default, so every streamed run reported 0 tokens /
# $0 cost. `stream_usage=True` adds stream_options.include_usage so the final chunk carries
# usage_metadata (Anthropic/Google already stream usage). Lets the tracer + meters price runs.
if provider == "openai":
clean.setdefault("stream_usage", True)
# Keep-alive: hand OpenAI models one process-wide connection pool so back-to-back turns and
# runs reuse a warm TLS connection instead of re-handshaking on each per-run graph compile.
# Scoped to OpenAI: langchain-openai accepts `http_async_client`, whereas the Anthropic and
# Google integrations manage (and pool) their own transport. Skipped if the caller already
# supplied a client (e.g. a test mock).
if (
settings.llm_http_keepalive
and provider == "openai"
and "http_async_client" not in clean
):
from forge.util.http import shared_llm_async_client
clean["http_async_client"] = shared_llm_async_client()
return init_chat_model(model_ref, **clean)
+77
View File
@@ -0,0 +1,77 @@
"""Node Type Registry - `node.type` -> factory + typed ports + schema id.
Doc 2 §6. New node types are added by registering a `NodeSpec`; the compiler,
the validator, and the UI palette all read from this registry without edits.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
# IOType enum (Doc 4 §1). A connection is valid iff source/target are compatible.
IO_TYPES = frozenset(
{"messages", "text", "json", "tool", "embedding", "vector", "any", "control"}
)
def io_compatible(source: str, target: str) -> bool:
"""`any` matches all; `control` only connects to `control`; else exact match."""
if source == "control" or target == "control":
return source == "control" and target == "control"
if source == "any" or target == "any":
return True
return source == target
@dataclass(frozen=True)
class Port:
id: str
io_type: str
direction: str # "in" | "out"
label: str | None = None
required: bool = True
many: bool = False
# A NodeFactory takes (validated config, CompileContext) and returns a LangGraph
# node - either a plain callable `(state) -> dict` or a compiled Runnable/graph.
NodeFactory = Callable[[dict, "object"], object]
# summarize(config) -> short glanceable lines for the canvas node body.
Summarizer = Callable[[dict], list[str]]
@dataclass
class NodeSpec:
type: str
schema_id: str
input_ports: list[Port]
output_ports: list[Port]
factory: NodeFactory
allows_cycle: bool = False
summarize: Summarizer | None = None
category: str = "flow" # flow|agents|model_tools|knowledge|human|integrations
label: str = ""
description: str = ""
NODE_REGISTRY: dict[str, NodeSpec] = {}
def register(spec: NodeSpec) -> NodeSpec:
if spec.type in NODE_REGISTRY:
raise ValueError(f"Node type already registered: {spec.type!r}")
NODE_REGISTRY[spec.type] = spec
return spec
def get_spec(node_type: str) -> NodeSpec:
try:
return NODE_REGISTRY[node_type]
except KeyError as e:
known = ", ".join(sorted(NODE_REGISTRY)) or "<none registered>"
raise KeyError(f"Unknown node type {node_type!r}. Registered: {known}") from e
def all_specs() -> list[NodeSpec]:
return list(NODE_REGISTRY.values())
+63
View File
@@ -0,0 +1,63 @@
"""Build a runtime state schema (TypedDict) from a workflow's declared state.
Doc 2 §6 / Doc 4 §2: state is a `TypedDict` (NOT pydantic, hard v1 constraint).
Each field maps to a python type + a reducer so parallel writes merge correctly.
"""
from __future__ import annotations
import operator
from typing import Annotated, Any, TypedDict
from langgraph.graph import add_messages
# Doc 4 StateFieldSpec.type -> python type used for the channel.
PY_TYPES: dict[str, Any] = {
"list[message]": list,
"list[str]": list,
"list[json]": list,
"str": str,
"int": int,
"float": float,
"bool": bool,
"json": dict,
}
def _merge(a: dict | None, b: dict | None) -> dict:
return {**(a or {}), **(b or {})}
# Doc 4 StateFieldSpec.reducer -> binary reducer. "last" => no reducer (LastValue/overwrite).
REDUCERS: dict[str, Any] = {
"add_messages": add_messages,
"add": operator.add,
"merge": _merge,
# "last" intentionally absent: a plain annotation => overwrite semantics.
}
# Sensible default so agent nodes always have a messages channel to read/write.
_DEFAULT_MESSAGES = {"type": "list[message]", "reducer": "add_messages"}
def build_state_typeddict(state_cfg: dict[str, dict], name: str = "WorkflowState") -> type:
"""Compile a state-schema dict into a `TypedDict` with reducer annotations.
Example input (executable JSON `state`):
{"messages": {"type": "list[message]", "reducer": "add_messages"},
"findings": {"type": "list[str]", "reducer": "add"},
"intent": {"type": "str", "reducer": "last"}}
"""
cfg = dict(state_cfg or {})
cfg.setdefault("messages", _DEFAULT_MESSAGES)
annotations: dict[str, Any] = {}
for field, spec in cfg.items():
py = PY_TYPES.get(spec.get("type", "json"), Any)
reducer_name = spec.get("reducer", "last")
reducer = REDUCERS.get(reducer_name)
annotations[field] = Annotated[py, reducer] if reducer is not None else py
# Functional TypedDict carries Annotated reducer metadata that LangGraph reads
# when building channels. total=False: nodes may write a partial state update.
return TypedDict(name, annotations, total=False) # type: ignore[operator]
+6
View File
@@ -0,0 +1,6 @@
"""RAG: embeddings, vector store (Chroma or pgvector), ingestion, Q&A, hybrid search."""
from forge.knowledge.embeddings import Embedder, resolve_embedder
from forge.knowledge.store import ChromaStore, Hit, make_store
__all__ = ["Embedder", "resolve_embedder", "ChromaStore", "Hit", "make_store"]
+119
View File
@@ -0,0 +1,119 @@
"""Website crawling for knowledge ingestion.
`crawl_site` does a same-domain BFS from a start URL (SSRF-guarded, redirect-safe) up to
`max_pages`/`max_depth`, honoring robots.txt with a small politeness delay between fetches, and
returns {url: text} so each page keeps its own provenance. `extract_links` (pure, testable)
pulls same-domain links from a page.
"""
from __future__ import annotations
import asyncio
from urllib.parse import urljoin, urlparse
from urllib.robotparser import RobotFileParser
# Bounds so a caller-supplied meta can't launch an unbounded crawl (DoS / runaway cost).
MAX_PAGES_CAP = 200
MAX_DEPTH_CAP = 5
DEFAULT_MAX_PAGES = 25
DEFAULT_MAX_DEPTH = 2
DEFAULT_DELAY_SECONDS = 0.3 # politeness gap between fetches (also raised to robots Crawl-delay)
_USER_AGENT = "ForgeKnowledgeBot"
def extract_links(html: str, base_url: str) -> list[str]:
"""Absolute, same-domain http(s) links from a page (deduped, fragments stripped)."""
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
hrefs = [a.get("href") for a in soup.find_all("a", href=True)]
except Exception: # noqa: BLE001 - fall back to a crude regex
import re
hrefs = re.findall(r'href=["\']([^"\']+)["\']', html or "")
base_host = urlparse(base_url).netloc
out: list[str] = []
for href in hrefs:
if not href:
continue
u = urljoin(base_url, href).split("#")[0]
p = urlparse(u)
if p.scheme in ("http", "https") and p.netloc == base_host and u not in out:
out.append(u)
return out
async def _load_robots(client, start_url: str) -> RobotFileParser | None:
"""Fetch + parse the site's robots.txt through the SSRF-guarded client (never urllib's own
opener, which would bypass the egress guard). None on any failure => treat as allow-all,
the standard behavior when a site publishes no robots.txt."""
from forge.util.ssrf import guarded_get
p = urlparse(start_url)
robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
try:
r = await guarded_get(client, robots_url, timeout=10, follow_redirects=True)
if r.status_code >= 400:
return None
rp = RobotFileParser()
rp.parse(r.text.splitlines())
return rp
except Exception: # noqa: BLE001 - unreachable / invalid robots -> allow-all
return None
def _allowed(rp: RobotFileParser | None, url: str) -> bool:
if rp is None:
return True
try:
return rp.can_fetch(_USER_AGENT, url)
except Exception: # noqa: BLE001 - be permissive if the parser chokes on an entry
return True
async def crawl_site(
start_url: str, max_pages: int = DEFAULT_MAX_PAGES, *,
max_depth: int = DEFAULT_MAX_DEPTH, delay: float = DEFAULT_DELAY_SECONDS,
) -> dict[str, str]:
"""Same-domain BFS from ``start_url``, returning {url: extracted_text}.
Honors robots.txt (skips disallowed URLs, respects any Crawl-delay), waits ``delay`` seconds
between fetches to stay polite, and stops at ``max_pages`` pages or ``max_depth`` link hops
from the start (both clamped to hard caps). Unreachable pages are skipped, not fatal.
"""
from forge.services.knowledge import _strip_html
from forge.util.http import shared_async_client
from forge.util.ssrf import guarded_get
max_pages = max(1, min(int(max_pages or DEFAULT_MAX_PAGES), MAX_PAGES_CAP))
max_depth = max(0, min(int(max_depth if max_depth is not None else DEFAULT_MAX_DEPTH), MAX_DEPTH_CAP))
client = shared_async_client()
rp = await _load_robots(client, start_url)
robots_delay = rp.crawl_delay(_USER_AGENT) if rp else None
delay = max(float(delay or 0.0), float(robots_delay or 0.0))
seen: set[str] = set()
queue: list[tuple[str, int]] = [(start_url, 0)]
pages: dict[str, str] = {}
first = True
while queue and len(pages) < max_pages:
url, depth = queue.pop(0)
if url in seen:
continue
seen.add(url)
if not _allowed(rp, url):
continue
if not first and delay > 0:
await asyncio.sleep(delay)
first = False
try:
r = await guarded_get(client, url, timeout=20, follow_redirects=True)
html = r.text
except Exception: # noqa: BLE001 - skip unreachable pages
continue
pages[url] = _strip_html(html)
if depth < max_depth:
for link in extract_links(html, url):
if link not in seen:
queue.append((link, depth + 1))
return pages
+254
View File
@@ -0,0 +1,254 @@
"""Embedders. The default is a local, open-source model via fastembed (ONNX, no API
key, no per-token cost) - it runs fully offline after a one-time model download. Set the
project's rag embedding_model to 'openai:text-embedding-3-*' (+ a key) for a hosted model.
There is deliberately no toy/hash fallback: if no real embedder can be built we raise a
clear error rather than silently returning meaningless vectors.
"""
from __future__ import annotations
import hashlib
import logging
import math
import os
from typing import Protocol
from forge.tracing.tracer import embedding_span
log = logging.getLogger("forge.embeddings")
class Embedder(Protocol):
name: str
dim: int
max_input_chars: int # safe char budget before the model truncates (see ingest clamp)
def embed(self, texts: list[str]) -> list[list[float]]: ...
def embed_query(self, text: str) -> list[float]: ...
async def aembed(self, texts: list[str]) -> list[list[float]]: ...
async def aembed_query(self, text: str) -> list[float]: ...
# Embedding dimensions per known model (a Chroma collection is fixed-dim; the
# collection name is keyed by dim, so this must be right per model).
_MODEL_DIMS = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
# fastembed (local ONNX) models - keyed by their model id (== the embedder.name we
# store on a source), so embedding_health can spot a dim change.
"BAAI/bge-small-en-v1.5": 384,
"BAAI/bge-base-en-v1.5": 768,
"BAAI/bge-large-en-v1.5": 1024,
}
# Default fastembed model when the ref is just "fastembed:" or unset (small, 384-dim, CPU-fast).
_DEFAULT_FASTEMBED = "BAAI/bge-small-en-v1.5"
# Max input sequence LENGTH (tokens) per model. An embedder silently TRUNCATES anything longer,
# so a chunk_size (in chars) that overflows this loses the tail of every chunk. Chunking is
# char-based, so we convert with a deliberately conservative chars/token ratio (dense text can
# be ~3-4 chars/token) to get a safe character budget - see `max_input_chars` + the ingest clamp.
_MODEL_MAX_TOKENS = {
"text-embedding-3-small": 8191, "text-embedding-3-large": 8191, "text-embedding-ada-002": 8191,
"BAAI/bge-small-en-v1.5": 512, "BAAI/bge-base-en-v1.5": 512, "BAAI/bge-large-en-v1.5": 512,
}
_DEFAULT_MAX_TOKENS = 512 # conservative fallback for an unmapped model
_CHARS_PER_TOKEN = 4 # rough English average; used only to derive a safe char budget from tokens
def _est_tokens(texts: list[str]) -> int:
"""Rough input-token estimate for pricing an embedding span (chars / ~4). Embedders don't
return token counts, so this drives the span's cost via pricing; latency is always exact."""
return sum(len(t or "") for t in texts) // _CHARS_PER_TOKEN
def _max_input_chars(model_name: str) -> int:
return _MODEL_MAX_TOKENS.get(model_name, _DEFAULT_MAX_TOKENS) * _CHARS_PER_TOKEN
# Every embedding dim a Chroma collection may have been created under: the known model dims
# plus 256 (the removed hashed FakeEmbedder's legacy dim). delete/reingest sweep ALL of these
# (see services.knowledge._dim_collections) so switching embedders can't leave orphaned
# vectors behind - a stale FAQ would otherwise keep deflecting, a stale chunk keep surfacing.
KNOWN_EMBEDDING_DIMS: frozenset[int] = frozenset({256, *_MODEL_DIMS.values()})
# Relevance floors calibrated to the DEFAULT local BGE embedder (repo audit + measured cosines):
# BGE query/doc cosine for RELATED pairs ~0.75-0.86, UNRELATED ~0.38-0.52, so a 0.6 floor cleanly
# separates them and lets a wildly off-topic query surface NOTHING (the grounded agent then says
# it doesn't know instead of answering from the nearest chunk). Hosted models (e.g. OpenAI) sit
# on a LOWER cosine scale - set a smaller min_score per project/node when using them.
DEFAULT_MIN_SCORE = 0.6
# Cross-encoder rerank floor on the sigmoid (0-1) scale. The default ms-marco reranker is sharply
# bimodal (relevant ~1.0, irrelevant ~0.0 measured), so 0.3 drops off-topic while keeping
# borderline-relevant passages. Applied ONLY when rerank is on (a different scale from cosine).
DEFAULT_RERANK_MIN_SCORE = 0.3
class _LCEmbedder:
"""Adapter over a LangChain embeddings object (e.g. OpenAIEmbeddings)."""
def __init__(self, emb, model_name: str) -> None:
self._e = emb
self.name = model_name
self.dim = _MODEL_DIMS.get(model_name, 1536)
self.max_input_chars = _max_input_chars(model_name)
def embed(self, texts: list[str]) -> list[list[float]]:
return self._e.embed_documents(texts)
def embed_query(self, text: str) -> list[float]:
return self._e.embed_query(text)
# Async variants keep network embed calls off the event loop's back (the sync
# ones block the loop - and the SSE stream - for the whole round trip).
async def aembed(self, texts: list[str]) -> list[list[float]]:
with embedding_span(self.name, n_texts=len(texts), input_tokens=_est_tokens(texts)):
return await self._e.aembed_documents(texts)
async def aembed_query(self, text: str) -> list[float]:
with embedding_span(self.name, n_texts=1, input_tokens=_est_tokens([text])):
return await self._e.aembed_query(text)
class _FastEmbedEmbedder:
"""Local, open-source embedder via fastembed (ONNX, no PyTorch, no API cost).
Model files download once to the HuggingFace cache on first use, then run fully
offline on CPU. Output dim is probed once at construction (it drives the dim-keyed
Chroma collection). Instances are cached in _EMBEDDER_CACHE so the ~model-load cost
is paid once per process.
"""
def __init__(self, model_name: str) -> None:
from fastembed import TextEmbedding
from forge.config import settings
# A configured cache dir points at the model baked into the Docker image (offline,
# no first-run download); None falls back to fastembed's own default temp cache.
cache_dir = settings.fastembed_cache_dir or None
self._model = TextEmbedding(model_name=model_name, cache_dir=cache_dir)
self.name = model_name
self.dim = len(next(iter(self._model.embed(["dim probe"]))))
self.max_input_chars = _max_input_chars(model_name)
def embed(self, texts: list[str]) -> list[list[float]]:
# fastembed yields numpy float32 arrays; Chroma wants plain float lists. `tolist()`
# returns native Python floats in one C-level call (far cheaper than a per-element
# `float(x)` comprehension over the vector).
return [v.tolist() for v in self._model.embed(list(texts))]
def embed_query(self, text: str) -> list[float]:
return self.embed([text])[0]
# fastembed is synchronous CPU work; run it in a thread so a batch embed doesn't
# block the event loop (mirrors the _LCEmbedder rationale for network calls).
async def aembed(self, texts: list[str]) -> list[list[float]]:
import asyncio
with embedding_span(self.name, n_texts=len(texts), input_tokens=_est_tokens(texts)):
return await asyncio.to_thread(self.embed, texts)
async def aembed_query(self, text: str) -> list[float]:
import asyncio
with embedding_span(self.name, n_texts=1, input_tokens=_est_tokens([text])):
return await asyncio.to_thread(self.embed_query, text)
# Provider embedder instances are expensive to construct (~1s measured on Windows:
# the OpenAI SDK builds two httpx clients = two SSL contexts), so cache per
# (model, key-fingerprint). The cache holds the client, not the key itself.
_EMBEDDER_CACHE: dict[tuple[str, str], Embedder] = {}
# Warn once per model when we fall back to the local default for a hosted-provider model
# - the dim-keyed collection then won't match content indexed under the hosted model.
_FALLBACK_WARNED: set[str] = set()
def _key_fp(api_key: str | None) -> str:
if not api_key:
return "env"
# Not a security hash: this only namespaces the in-process embedder cache by key, so the
# plaintext key never becomes a dict key. usedforsecurity=False documents that intent.
return hashlib.sha256(api_key.encode(), usedforsecurity=False).hexdigest()[:16]
def _warn_once(model: str, msg: str) -> None:
if model not in _FALLBACK_WARNED:
_FALLBACK_WARNED.add(model)
log.warning("resolve_embedder: %s (model=%r)", msg, model)
def _resolve_fastembed(name: str) -> Embedder:
"""Build (or reuse) a local fastembed embedder. Raises RuntimeError with an
actionable message if fastembed / the model isn't available - there is no toy
fallback, so callers surface a clear error instead of silently-wrong results."""
cache_key = (f"fastembed:{name}", "fastembed")
hit = _EMBEDDER_CACHE.get(cache_key)
if hit is not None:
return hit
try:
emb = _FastEmbedEmbedder(name)
except Exception as e: # noqa: BLE001 - fastembed missing / model download failed
raise RuntimeError(
f"Local embedder {name!r} unavailable: {e}. Install the 'knowledge' extra "
"(fastembed) and ensure the model can be downloaded, or set an OpenAI embedding "
"model + API key in project settings."
) from e
_EMBEDDER_CACHE[cache_key] = emb
return emb
def resolve_embedder(model: str | None = None, api_key: str | None = None) -> Embedder:
"""Return an embedder for the given model ref + (project) key.
Default (unset or 'fastembed:<model>') is a local open-source ONNX embedder - no key,
no API cost, offline after a one-time model download. 'openai:text-embedding-3-*' uses
a hosted OpenAI model when a key resolves (per-project key first, then OPENAI_API_KEY);
without a key, or on any construction failure, we fall back to the local default and
warn once. A Chroma collection is fixed-dim, so we key it by `embedder.dim` (switching
models needs a re-embed). Instances are cached.
"""
# Local open-source default (also the ':'-only or unset ref).
if not model or model.startswith("fastembed:"):
name = model.split(":", 1)[1].strip() if (model and ":" in model) else ""
return _resolve_fastembed(name or _DEFAULT_FASTEMBED)
if model.startswith("openai:"):
if api_key or os.environ.get("OPENAI_API_KEY"):
cache_key = (model, _key_fp(api_key))
hit = _EMBEDDER_CACHE.get(cache_key)
if hit is not None:
return hit
try:
from langchain_openai import OpenAIEmbeddings
name = model.split(":", 1)[1]
kwargs: dict = {"model": name}
if api_key:
kwargs["api_key"] = api_key
emb = _LCEmbedder(OpenAIEmbeddings(**kwargs), name)
_EMBEDDER_CACHE[cache_key] = emb
return emb
except Exception: # noqa: BLE001 - fall back to the local default (but make it visible)
_warn_once(model, "could not construct OpenAIEmbeddings; falling back to local fastembed (dim differs - re-embed)")
return _resolve_fastembed(_DEFAULT_FASTEMBED)
# A hosted model was requested but no key resolved. Falling back to the local
# default indexes/queries a DIFFERENT (dim-keyed) collection than the hosted model
# would - the dim-flip trap that makes RAG/Q&A go quietly empty. Make it loud (once).
_warn_once(model, "no OpenAI API key resolved; falling back to local fastembed (dim differs - re-embed)")
return _resolve_fastembed(_DEFAULT_FASTEMBED)
# Unrecognized provider prefix -> local default rather than a hard failure.
_warn_once(model, "unrecognized embedding model; falling back to local fastembed")
return _resolve_fastembed(_DEFAULT_FASTEMBED)
def cosine(a: list[float], b: list[float]) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b, strict=False))
na = math.sqrt(sum(x * x for x in a)) or 1.0
nb = math.sqrt(sum(x * x for x in b)) or 1.0
return dot / (na * nb)
+70
View File
@@ -0,0 +1,70 @@
"""Hybrid retrieval helpers: BM25 lexical ranking + Reciprocal Rank Fusion (RRF).
Dense vectors catch paraphrase ("how long for a refund" ~ "return processing time");
BM25 catches exact/rare terms vectors blur (error codes, SKUs, proper nouns). RRF
combines the two ranked lists by *position*, so their very different score scales
(cosine ~[0,1] vs unbounded BM25) never have to be normalized against each other.
All pure functions + graceful degradation: if rank_bm25 isn't installed, bm25_rank
returns [] and the caller falls back to vector-only - hybrid never hard-fails.
"""
from __future__ import annotations
import re
_TOKEN = re.compile(r"[a-z0-9]+")
_RRF_K = 60 # standard RRF damping constant (Cormack et al.)
def tokenize(text: str) -> list[str]:
return _TOKEN.findall((text or "").lower())
def build_bm25(docs: list[tuple[str, str]]) -> tuple | None:
"""Build a REUSABLE BM25 index over (id, text) docs -> (bm25, ids), or None when
rank_bm25 is absent / the corpus is empty / nothing tokenizes. Split out from
``bm25_rank`` so the store can cache the index (the expensive part) per corpus version
and re-run only the cheap per-query scoring (see store._build_lexical_index)."""
if not docs:
return None
try:
from rank_bm25 import BM25Okapi
except Exception: # noqa: BLE001 - knowledge extra not installed -> vector-only
return None
tokenized = [tokenize(t) for _, t in docs]
if not any(tokenized):
return None
return BM25Okapi(tokenized), [d[0] for d in docs]
def bm25_scores(index: tuple | None, query: str) -> list[str]:
"""Score a prebuilt index (from ``build_bm25``) against ``query``; ids best-first,
positives only. [] when the index is empty or the query has no usable tokens."""
if not index:
return []
bm25, ids = index
if bm25 is None or not ids:
return []
q = tokenize(query)
if not q:
return []
scores = bm25.get_scores(q)
ranked = sorted(range(len(ids)), key=lambda i: scores[i], reverse=True)
return [ids[i] for i in ranked if scores[i] > 0]
def bm25_rank(query: str, docs: list[tuple[str, str]]) -> list[str]:
"""Rank (id, text) candidates by BM25 against ``query``; ids best-first, positives
only. Returns [] when rank_bm25 is absent, the corpus is empty, or nothing matches.
Thin wrapper over build_bm25 + bm25_scores (kept for the one-shot / test call sites)."""
return bm25_scores(build_bm25(docs), query)
def rrf_fuse(*ranked_lists: list[str], k: int = _RRF_K) -> dict[str, float]:
"""Reciprocal Rank Fusion over any number of ranked id lists -> {id: fused_score}."""
fused: dict[str, float] = {}
for ids in ranked_lists:
for rank, _id in enumerate(ids):
fused[_id] = fused.get(_id, 0.0) + 1.0 / (k + rank + 1)
return fused
+309
View File
@@ -0,0 +1,309 @@
"""pgvector-backed EmbeddingStore - the production alternative to the embedded ChromaStore.
Selected by `settings.vector_backend == "pgvector"` via `store.make_store()`. Unlike Chroma
(an on-disk index a single process owns), every worker connects to the same Postgres, so
vectors are shared across a horizontally-scaled deployment.
Design
------
- One table (`kb_vectors`) holds every collection's rows; the `collection` column carries the
dim-keyed name (e.g. forge_kb_384) the caller already uses, so different embedders never mix
in a distance comparison (each query is scoped to one collection => one dim).
- The `embedding` column is an unmodified `vector` (no fixed dimension) so collections of
different dims coexist in the table. Searches are exact (a scoped sequential scan ordered by
cosine distance) - correct and simple; an ANN index (HNSW) is a per-dim follow-up.
- Chunk metadata is stored as JSONB and the Chroma-style where-dicts the callers build
($and / $eq / $in over metadata fields) are translated to SQL predicates over it, so the
store is a drop-in for ChromaStore without changing any call site.
Connections are opened per operation (psycopg v3, synchronous - store methods are already
called off the event loop in a threadpool), which is the thread-safe choice and needs no
connection-pool dependency. Requires Postgres with the `vector` extension available.
Interface parity with ChromaStore: upsert / query / query_where / hybrid_query / dump /
delete_ids / delete_by_source / delete_where / count / count_where / list_docs / get_texts /
ids_where.
"""
from __future__ import annotations
import json
import threading
from forge.config import settings
from forge.knowledge.store import _CORPUS_CAP, Hit, _bump_version, _hybrid_fuse, _where
_TABLE = "kb_vectors"
# DSNs whose schema (extension + table + indexes) has been ensured this process.
_INITIALIZED: set[str] = set()
_INIT_LOCK = threading.Lock()
def _sync_dsn() -> str:
"""A libpq-compatible DSN for psycopg from the app's async database_url (drop the
SQLAlchemy driver suffix). pgvector requires Postgres; a sqlite url is a misconfiguration."""
url = settings.database_url
if url.startswith("sqlite"):
raise RuntimeError(
"vector_backend='pgvector' requires a Postgres database_url; got a sqlite url."
)
return url.replace("+asyncpg", "").replace("+psycopg", "")
def _to_vector_literal(embedding) -> str:
# pgvector accepts the text form '[v1,v2,...]'; we cast it with ::vector in SQL.
return "[" + ",".join(repr(float(x)) for x in embedding) + "]"
def _translate_where(where: dict | None, params: list) -> str:
"""Translate the Chroma where-dict subset Forge builds ($and/$or of {field: {$eq|$in}},
plus the {field: value} shorthand) into a SQL predicate over the JSONB `metadata` column.
Field names AND values are bound as parameters (never string-formatted), so this is
injection-safe even though the fields are internal. Unsupported operators fail closed."""
if not where:
return "TRUE"
if "$and" in where:
return "(" + " AND ".join(_translate_where(c, params) for c in where["$and"]) + ")"
if "$or" in where:
return "(" + " OR ".join(_translate_where(c, params) for c in where["$or"]) + ")"
clauses: list[str] = []
for field, cond in where.items():
cond = cond if isinstance(cond, dict) else {"$eq": cond}
for op, val in cond.items():
if op == "$eq":
params.extend([field, str(val)])
clauses.append("(metadata->>%s) = %s")
elif op == "$in":
params.extend([field, [str(v) for v in (val or [])]])
clauses.append("(metadata->>%s) = ANY(%s)")
else:
raise ValueError(f"pgvector store: unsupported where operator {op!r}")
return "(" + " AND ".join(clauses) + ")" if len(clauses) > 1 else (clauses[0] if clauses else "TRUE")
class PgVectorStore:
def __init__(self, collection: str = "forge_kb") -> None:
self._collection = collection
self._dsn = _sync_dsn()
# `_key` namespaces the shared BM25 cache (store._hybrid_fuse) per DSN + collection.
self._key = (self._dsn, collection)
self._ensure_schema()
# --- connection / schema ------------------------------------------------------------
def _connect(self):
import psycopg
return psycopg.connect(self._dsn)
def _ensure_schema(self) -> None:
if self._dsn in _INITIALIZED:
return
with _INIT_LOCK:
if self._dsn in _INITIALIZED:
return
with self._connect() as conn, conn.cursor() as cur:
# The extension may already be installed by an admin without CREATE privilege
# for the app role; ignore a permission failure and rely on the type existing.
try:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.commit()
except Exception: # noqa: BLE001 - extension pre-provisioned / no privilege
conn.rollback()
cur.execute(
f"""
CREATE TABLE IF NOT EXISTS {_TABLE} (
collection text NOT NULL,
id text NOT NULL,
document text,
metadata jsonb NOT NULL DEFAULT '{{}}'::jsonb,
embedding vector NOT NULL,
PRIMARY KEY (collection, id)
)
"""
)
cur.execute(
f"CREATE INDEX IF NOT EXISTS ix_{_TABLE}_metadata ON {_TABLE} USING gin (metadata)"
)
conn.commit()
_INITIALIZED.add(self._dsn)
def _scope(self, where: dict | None, params: list) -> str:
"""Collection scope + the translated metadata predicate (collection first so a scan
only ever compares vectors of one dimension)."""
params.append(self._collection)
return "collection = %s AND " + _translate_where(where, params)
# --- writes -------------------------------------------------------------------------
def upsert(self, *, ids, embeddings, documents, metadatas) -> None:
if not ids:
return
rows = [
(self._collection, _id, documents[i], json.dumps(metadatas[i] or {}),
_to_vector_literal(embeddings[i]))
for i, _id in enumerate(ids)
]
with self._connect() as conn, conn.cursor() as cur:
cur.executemany(
f"""
INSERT INTO {_TABLE} (collection, id, document, metadata, embedding)
VALUES (%s, %s, %s, %s::jsonb, %s::vector)
ON CONFLICT (collection, id) DO UPDATE SET
document = EXCLUDED.document,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding
""",
rows,
)
conn.commit()
_bump_version(self._key)
def delete_ids(self, ids: list[str]) -> None:
if not ids:
return
with self._connect() as conn, conn.cursor() as cur:
cur.execute(
f"DELETE FROM {_TABLE} WHERE collection = %s AND id = ANY(%s)",
[self._collection, list(ids)],
)
conn.commit()
_bump_version(self._key)
def delete_by_source(self, source_id: str, *, tenant_id: str | None = None, project_id: str | None = None) -> None:
clauses: list[dict] = [{"source_id": {"$eq": source_id}}]
if tenant_id:
clauses.append({"tenant_id": {"$eq": tenant_id}})
if project_id:
clauses.append({"project_id": {"$eq": project_id}})
self.delete_where({"$and": clauses} if len(clauses) > 1 else clauses[0])
def delete_where(self, where: dict) -> None:
params: list = []
scope = self._scope(where, params)
with self._connect() as conn, conn.cursor() as cur:
cur.execute(f"DELETE FROM {_TABLE} WHERE {scope}", params)
conn.commit()
_bump_version(self._key)
# --- reads --------------------------------------------------------------------------
def query(self, *, embedding, tenant_id, project_id, top_k=5, source_ids=None) -> list[Hit]:
return self.query_where(
embedding=embedding, where=_where(tenant_id, project_id, source_ids), top_k=top_k
)
def query_where(self, *, embedding, where: dict, top_k: int = 5) -> list[Hit]:
qvec = _to_vector_literal(embedding)
params: list = [qvec] # SELECT score term
scope = self._scope(where, params)
params.extend([qvec, top_k]) # ORDER BY term + LIMIT
sql = (
f"SELECT id, document, 1 - (embedding <=> %s::vector) AS score, metadata "
f"FROM {_TABLE} WHERE {scope} ORDER BY embedding <=> %s::vector LIMIT %s"
)
with self._connect() as conn, conn.cursor() as cur:
cur.execute(sql, params)
return [
Hit(id=r[0], text=r[1] or "", score=float(r[2]), metadata=r[3] or {})
for r in cur.fetchall()
]
def _get_documents(self, where: dict, limit: int | None = None) -> list[Hit]:
"""All stored chunks matching `where` (no vector query) - the corpus a lexical index
is built over. score is 0.0 (unranked); `limit` caps the scan."""
params: list = []
scope = self._scope(where, params)
sql = f"SELECT id, document, metadata FROM {_TABLE} WHERE {scope}"
if limit:
sql += " LIMIT %s"
params.append(limit)
with self._connect() as conn, conn.cursor() as cur:
cur.execute(sql, params)
return [Hit(id=r[0], text=r[1] or "", score=0.0, metadata=r[2] or {}) for r in cur.fetchall()]
def hybrid_query(
self, *, embedding, query: str, tenant_id, project_id, top_k=5, source_ids=None,
candidate_pool: int | None = None, corpus_cap: int = _CORPUS_CAP,
) -> list[Hit]:
"""Dense+BM25 RRF fusion, identical semantics to ChromaStore (see store._hybrid_fuse)."""
return _hybrid_fuse(
self, embedding=embedding, query=query, where=_where(tenant_id, project_id, source_ids),
top_k=top_k, candidate_pool=candidate_pool, corpus_cap=corpus_cap,
)
def dump(self, where: dict, limit: int | None = None, *, ids: list[str] | None = None) -> dict:
"""Raw rows INCLUDING embedding vectors (parsed back to float lists) - the input to the
chunk map's dimensionality reduction. Matches ChromaStore.dump's shape."""
if ids:
params: list = [self._collection, list(ids)]
sql = f"SELECT id, document, metadata, embedding::text FROM {_TABLE} WHERE collection = %s AND id = ANY(%s)"
else:
params = []
scope = self._scope(where, params)
sql = f"SELECT id, document, metadata, embedding::text FROM {_TABLE} WHERE {scope}"
if limit:
sql += " LIMIT %s"
params.append(limit)
try:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(sql, params)
out = {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
for r in cur.fetchall():
out["ids"].append(r[0])
out["documents"].append(r[1] or "")
out["metadatas"].append(r[2] or {})
out["embeddings"].append(json.loads(r[3]) if r[3] else [])
return out
except Exception: # noqa: BLE001 - table empty / not ready
return {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
def count(self, tenant_id: str, project_id: str) -> int:
return self.count_where(_where(tenant_id, project_id, None))
def count_where(self, where: dict) -> int:
params: list = []
scope = self._scope(where, params)
try:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(f"SELECT count(*) FROM {_TABLE} WHERE {scope}", params)
return int(cur.fetchone()[0])
except Exception: # noqa: BLE001
return 0
def list_docs(self, where: dict) -> dict:
"""ids + documents + metadatas (NO embeddings) for `where`."""
rows = self._get_documents(where)
return {
"ids": [h.id for h in rows],
"documents": [h.text for h in rows],
"metadatas": [h.metadata for h in rows],
}
def get_texts(self, ids: list[str], where: dict) -> dict:
"""Documents + metadatas for specific `ids`, ADDITIONALLY constrained by `where` - so a
caller-supplied id can't read a row outside its tenant/project."""
if not ids:
return {"ids": [], "documents": [], "metadatas": []}
params: list = []
scope = self._scope(where, params)
params.append(list(ids))
sql = f"SELECT id, document, metadata FROM {_TABLE} WHERE {scope} AND id = ANY(%s)"
try:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
except Exception: # noqa: BLE001
return {"ids": [], "documents": [], "metadatas": []}
return {
"ids": [r[0] for r in rows],
"documents": [r[1] or "" for r in rows],
"metadatas": [r[2] or {} for r in rows],
}
def ids_where(self, where: dict) -> list[str]:
params: list = []
scope = self._scope(where, params)
try:
with self._connect() as conn, conn.cursor() as cur:
cur.execute(f"SELECT id FROM {_TABLE} WHERE {scope}", params)
return [r[0] for r in cur.fetchall()]
except Exception: # noqa: BLE001
return []
+139
View File
@@ -0,0 +1,139 @@
"""Two-stage retrieval: a local cross-encoder re-ranker (stage 2).
Vector/hybrid search (stage 1) is fast but coarse - it scores every candidate against the
query independently. A cross-encoder reads the query AND a candidate *together*, so it judges
relevance far more accurately - at a cost, which is why it only ever runs over a small
shortlist (top-N) that stage 1 already narrowed down.
Fully local + offline, no new dependency: it reuses ``fastembed`` (already the default
embedder backend) via its ``TextCrossEncoder`` - ONNX on CPU, model files download once to the
same HuggingFace cache the embedders use. Like ``bm25_rank`` in hybrid.py, this NEVER hard-fails:
if the model can't load (knowledge extra absent / offline first run / bad model id) the input
order is returned unchanged and a warning is logged once. Re-ranking is opt-in per retrieval
node (or the search debugger); default retrieval is unchanged.
"""
from __future__ import annotations
import logging
import math
from dataclasses import replace
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from forge.knowledge.store import Hit
log = logging.getLogger("forge.rerank")
# Small, CPU-fast cross-encoder (~80 MB). BAAI/bge-reranker-base (~1 GB) is a heavier, higher-
# quality alternative a project can opt into via rag_defaults.reranker_model.
DEFAULT_RERANKER = "Xenova/ms-marco-MiniLM-L-6-v2"
# Warn once per model when a reranker can't be built, so a missing model degrades quietly to
# stage-1 order instead of spamming logs on every query.
_FALLBACK_WARNED: set[str] = set()
class Reranker(Protocol):
name: str
def scores(self, query: str, docs: list[str]) -> list[float]: ...
def _sigmoid(x: float) -> float:
# Cross-encoder outputs are unbounded logits; squash to (0,1) so a re-ranked Hit.score
# keeps the same 0..1 scale as cosine/fused scores and a downstream min_score still filters.
if x >= 0:
return 1.0 / (1.0 + math.exp(-x))
ex = math.exp(x)
return ex / (1.0 + ex)
class _FastEmbedReranker:
"""Adapter over fastembed's TextCrossEncoder (ONNX cross-encoder, CPU, offline)."""
def __init__(self, model_name: str) -> None:
from fastembed.rerank.cross_encoder import TextCrossEncoder
from forge.config import settings
# Reuse the same baked/offline model cache the embedders use (falls back to fastembed's
# own default temp cache when unset).
cache_dir = settings.fastembed_cache_dir or None
self._model = TextCrossEncoder(model_name=model_name, cache_dir=cache_dir)
self.name = model_name
def scores(self, query: str, docs: list[str]) -> list[float]:
# TextCrossEncoder.rerank yields one raw score per doc, in input order.
return [float(s) for s in self._model.rerank(query, list(docs))]
# Rerankers are expensive to construct (ONNX model load); cache one per model id process-wide,
# mirroring the embedder cache in embeddings.py.
_RERANKER_CACHE: dict[str, Reranker] = {}
def _normalize_model(model: str | None) -> str:
"""Accept a bare id ('Xenova/...') or a 'fastembed:<id>' ref (matching the embedding_model
convention); '' / None / ':'-only -> the default reranker."""
if not model:
return DEFAULT_RERANKER
m = model.strip()
if m.startswith("fastembed:"):
m = m.split(":", 1)[1].strip()
return m or DEFAULT_RERANKER
def resolve_reranker(model: str | None = None) -> Reranker | None:
"""Build (or reuse) a local cross-encoder re-ranker. Returns None (never raises) if the
model can't be constructed, so callers fall back to stage-1 order."""
name = _normalize_model(model)
hit = _RERANKER_CACHE.get(name)
if hit is not None:
return hit
try:
rr = _FastEmbedReranker(name)
except Exception as e: # noqa: BLE001 - fastembed/model unavailable -> no rerank
if name not in _FALLBACK_WARNED:
_FALLBACK_WARNED.add(name)
log.warning("resolve_reranker: %r unavailable (%s); using stage-1 order", name, e)
return None
_RERANKER_CACHE[name] = rr
return rr
def rerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
"""Re-order ``hits`` by cross-encoder relevance to ``query`` and keep the top ``top_k``.
Each returned Hit's ``score`` is the sigmoid of the cross-encoder logit (0..1), so
min_score filtering downstream still works. Degrades to ``hits[:top_k]`` (unchanged order)
when there is nothing to do or the reranker can't be built - never hard-fails.
"""
if not hits or not (query or "").strip():
return hits[:top_k]
reranker = resolve_reranker(model)
if reranker is None:
return hits[:top_k]
try:
raw = reranker.scores(query, [h.text for h in hits])
except Exception: # noqa: BLE001 - inference failure -> stage-1 order
log.warning("rerank_hits: cross-encoder inference failed; using stage-1 order", exc_info=True)
return hits[:top_k]
# A score-count mismatch would let zip() silently drop the unpaired tail hits; treat it as a
# failure and fall back to stage-1 order rather than returning a truncated/misaligned set.
if len(raw) != len(hits):
log.warning("rerank_hits: got %d scores for %d hits; using stage-1 order", len(raw), len(hits))
return hits[:top_k]
scored = sorted(
(replace(h, score=round(_sigmoid(s), 4)) for h, s in zip(hits, raw, strict=True)),
key=lambda h: h.score,
reverse=True,
)
return scored[:top_k]
async def arerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
"""Async wrapper: run the (CPU-bound) cross-encoder off the event loop."""
import asyncio
return await asyncio.to_thread(rerank_hits, query, hits, top_k=top_k, model=model)
+280
View File
@@ -0,0 +1,280 @@
"""Pluggable chunking strategies for knowledge ingestion.
Four strategies, all targeting ~chunk_size characters with overlap:
- ``recursive`` (default): LangChain's RecursiveCharacterTextSplitter when the
``knowledge`` extra is installed, falling back to the dependency-free recursive
character splitter below. Best general-purpose choice.
- ``section``: split on Markdown headers / heading lines so each chunk is a whole
section. Meaningful when the document is well structured (docs, wikis, crawled
pages which are concatenated under ``# {url}`` headers). Oversized sections are
recursively sub-split so a chunk never blows past chunk_size.
- ``sentence``: split on sentence boundaries (abbreviation-aware regex) then pack
sentences into chunks with sentence-level overlap. Meaningful when meaning lives
at the sentence level (FAQs, transcripts, prose).
- ``semantic``: split where the *meaning* shifts. Embeds each sentence and cuts at
the largest drops in sentence-to-sentence similarity (percentile break-points), so
a chunk stays on one topic. Needs an ``embed_fn`` (the ingest pipeline injects the
project embedder) - without one, or for very short text, it falls back to recursive.
It is the only strategy that isn't purely lexical; the others need no model.
``chunk_text`` is the single dispatch entrypoint; ``split_text`` is kept as the
pure-Python recursive splitter (public API + the universal fallback).
"""
from __future__ import annotations
import re
from collections.abc import Callable
# A callable that embeds a batch of texts -> one vector (list of floats) each. Matches the
# Embedder.embed signature in knowledge/embeddings.py, so ingest can pass `embedder.embed`.
EmbedFn = Callable[[list[str]], list]
# Canonical strategy names (kept in sync with packages/schemas/forge/project.json
# rag_defaults.chunking_strategy and KbSourceCreate.chunking_strategy).
CHUNK_STRATEGIES = ("recursive", "section", "sentence", "semantic")
DEFAULT_STRATEGY = "recursive"
# Break sentences into a new chunk when their similarity drop is in the top (100-N)% of drops.
# 95 => cut only at the sharpest ~5% of topic shifts (few, clean boundaries).
_SEMANTIC_BREAKPOINT_PERCENTILE = 95.0
_SEPARATORS = ["\n\n", "\n", ". ", " "]
# Markdown ATX headers (# .. ######) anchor section boundaries.
_HEADER_RE = re.compile(r"^#{1,6}[ \t]+\S.*$", re.MULTILINE)
# Sentence boundary: end punctuation followed by whitespace then an opener
# (capital letter, digit, or quote/paren). Lookarounds keep the delimiter attached
# to the left sentence.
_SENT_BOUNDARY = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=[A-Z0-9"\'(\[])')
# Common abbreviations whose trailing period is NOT a sentence end. Compared
# lowercased with the trailing dot stripped.
_ABBREVIATIONS = frozenset({
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "eg", "ie",
"e.g", "i.e", "inc", "ltd", "co", "corp", "no", "fig", "al", "approx", "dept",
"est", "u.s", "u.k", "a.m", "p.m", "vol", "pp",
})
def chunk_text(
text: str, *, strategy: str = DEFAULT_STRATEGY, chunk_size: int = 1000, overlap: int = 200,
embed_fn: EmbedFn | None = None,
) -> list[str]:
"""Split ``text`` using the named strategy. Unknown/empty strategy -> recursive.
``embed_fn`` is only used by the ``semantic`` strategy (the ingest pipeline passes the
project embedder). Every other strategy ignores it and stays fully offline/lexical.
"""
text = (text or "").strip()
if not text:
return []
strategy = (strategy or DEFAULT_STRATEGY).strip().lower()
if strategy == "section":
return _split_sections(text, chunk_size, overlap)
if strategy == "sentence":
return _split_sentences(text, chunk_size, overlap)
if strategy == "semantic":
return _split_semantic(text, chunk_size, overlap, embed_fn)
return _split_recursive(text, chunk_size, overlap)
def split_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
"""Dependency-free recursive character splitter (~chunk_size with overlap).
Kept as a stable public API and as the universal fallback for every strategy.
"""
text = (text or "").strip()
if len(text) <= chunk_size:
return [text] if text else []
# Find the best separator that produces pieces, then greedily pack into chunks.
pieces = _split_on_separators(text, _SEPARATORS, chunk_size)
chunks: list[str] = []
cur = ""
for p in pieces:
if len(cur) + len(p) + 1 <= chunk_size:
cur = f"{cur} {p}".strip() if cur else p
else:
if cur:
chunks.append(cur)
# carry overlap from the tail of the previous chunk
tail = cur[-overlap:] if overlap and cur else ""
cur = (f"{tail} {p}").strip() if tail else p
if cur:
chunks.append(cur)
return [c for c in chunks if c.strip()]
def _split_on_separators(text: str, seps: list[str], chunk_size: int) -> list[str]:
if len(text) <= chunk_size or not seps:
return [text]
sep = seps[0]
parts = text.split(sep) if sep in text else [text]
out: list[str] = []
for part in parts:
if len(part) <= chunk_size:
out.append(part)
else:
out.extend(_split_on_separators(part, seps[1:], chunk_size))
return out
def _split_recursive(text: str, chunk_size: int, overlap: int) -> list[str]:
"""Recursive-character strategy: prefer LangChain's splitter, else fall back."""
try:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size, chunk_overlap=min(overlap, max(chunk_size - 1, 0))
)
chunks = [c.strip() for c in splitter.split_text(text) if c and c.strip()]
if chunks:
return chunks
except Exception: # noqa: BLE001 - knowledge extra absent / splitter error -> fallback
pass
return split_text(text, chunk_size, overlap)
def _split_sections(text: str, chunk_size: int, overlap: int) -> list[str]:
"""Each Markdown section (header + body up to the next header) becomes a chunk;
oversized sections are recursively sub-split. Falls back to recursive when the
document has no headers to key off."""
matches = list(_HEADER_RE.finditer(text))
if not matches:
return _split_recursive(text, chunk_size, overlap)
sections: list[str] = []
# Any preamble before the first header is its own section.
if matches[0].start() > 0:
pre = text[: matches[0].start()].strip()
if pre:
sections.append(pre)
for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
sec = text[m.start():end].strip()
if sec:
sections.append(sec)
chunks: list[str] = []
for sec in sections:
if len(sec) <= chunk_size:
chunks.append(sec)
else:
chunks.extend(_split_recursive(sec, chunk_size, overlap))
return [c for c in chunks if c.strip()]
def _split_into_sentences(text: str) -> list[str]:
raw = _SENT_BOUNDARY.split(text)
sentences: list[str] = []
for piece in raw:
s = piece.strip()
if not s:
continue
# Re-merge if the previous sentence ended on a known abbreviation (the
# boundary regex split too eagerly after e.g. "Dr." or "U.S.").
if sentences:
last_word = re.split(r"\s+", sentences[-1])[-1].rstrip(".").lower()
if last_word in _ABBREVIATIONS:
sentences[-1] = f"{sentences[-1]} {s}"
continue
sentences.append(s)
return sentences
def _split_sentences(text: str, chunk_size: int, overlap: int) -> list[str]:
"""Pack whole sentences into ~chunk_size chunks, carrying trailing sentences
forward as overlap. A single over-long sentence is recursively sub-split."""
sentences = _split_into_sentences(text)
if not sentences:
return _split_recursive(text, chunk_size, overlap)
chunks: list[str] = []
cur: list[str] = []
cur_len = 0
for s in sentences:
if len(s) > chunk_size:
if cur:
chunks.append(" ".join(cur))
cur, cur_len = [], 0
chunks.extend(_split_recursive(s, chunk_size, overlap))
continue
if cur and cur_len + len(s) + 1 > chunk_size:
chunks.append(" ".join(cur))
# Sentence-level overlap: carry the trailing sentences up to ~overlap chars.
carry: list[str] = []
carry_len = 0
for prev in reversed(cur):
if carry_len + len(prev) + 1 > overlap:
break
carry.insert(0, prev)
carry_len += len(prev) + 1
cur, cur_len = carry, carry_len
cur.append(s)
cur_len += len(s) + 1
if cur:
chunks.append(" ".join(cur))
return [c for c in chunks if c.strip()]
def _percentile(sorted_vals: list[float], pct: float) -> float:
"""Linear-interpolated percentile over an already-sorted list (no numpy dependency)."""
if not sorted_vals:
return 0.0
if len(sorted_vals) == 1:
return sorted_vals[0]
rank = (pct / 100.0) * (len(sorted_vals) - 1)
lo = int(rank)
hi = min(lo + 1, len(sorted_vals) - 1)
return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (rank - lo)
def _split_semantic(
text: str, chunk_size: int, overlap: int, embed_fn: EmbedFn | None
) -> list[str]:
"""Split on meaning-drift: embed each sentence, then cut between sentences whose
similarity drop is among the sharpest (strictly above the break-point percentile). A single
topic stays in one chunk; a topic shift starts a new one. Segments larger than chunk_size are
recursively sub-split. Falls back to recursive when there's no embedder, too few
sentences to compare, or embedding fails - so it can never hard-fail an ingest."""
if embed_fn is None:
return _split_recursive(text, chunk_size, overlap)
sentences = _split_into_sentences(text)
if len(sentences) < 3:
return _split_recursive(text, chunk_size, overlap)
try:
vectors = list(embed_fn(sentences))
except Exception: # noqa: BLE001 - embedder failure -> lexical fallback, never abort ingest
return _split_recursive(text, chunk_size, overlap)
if len(vectors) != len(sentences):
return _split_recursive(text, chunk_size, overlap)
from forge.knowledge.embeddings import cosine
# Distance (1 - cosine) between each adjacent sentence pair; a large value = a topic shift.
# Materialize each vector to a list ONCE (each is otherwise re-listed as both a left and a
# right neighbor).
vecs = [list(v) for v in vectors]
dists = [1.0 - cosine(vecs[i], vecs[i + 1]) for i in range(len(vecs) - 1)]
threshold = _percentile(sorted(dists), _SEMANTIC_BREAKPOINT_PERCENTILE)
segments: list[str] = []
cur = [sentences[0]]
for i in range(1, len(sentences)):
if dists[i - 1] > threshold: # sharp enough drop -> boundary before this sentence
segments.append(" ".join(cur))
cur = []
cur.append(sentences[i])
if cur:
segments.append(" ".join(cur))
chunks: list[str] = []
for seg in segments:
if len(seg) <= chunk_size:
chunks.append(seg)
else:
chunks.extend(_split_recursive(seg, chunk_size, overlap))
return [c for c in chunks if c.strip()]
+314
View File
@@ -0,0 +1,314 @@
"""Chroma-backed EmbeddingStore (the user-mandated vector store).
Embedded persistent client (no server). One collection, scoped by tenant_id +
project_id metadata so it's multi-tenant. We pass embeddings explicitly (our own
embedder), so Chroma never needs to download its default model - fully offline.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, replace
from forge.config import settings
@dataclass
class Hit:
id: str
text: str
score: float
metadata: dict
# In hybrid mode `score` is the normalized RRF fusion RANK (top≈1.0), NOT cosine, so a
# cosine floor (min_score) can't be applied to it. `vector_score` carries the underlying
# dense cosine similarity for the SAME chunk so callers can threshold the true scale (see
# nodes/rag.py). None when the chunk surfaced from BM25 only (no dense score to compare).
vector_score: float | None = None
def _where(tenant_id: str, project_id: str, source_ids: list[str] | None) -> dict:
clauses: list[dict] = [{"tenant_id": {"$eq": tenant_id}}, {"project_id": {"$eq": project_id}}]
if source_ids:
clauses.append({"source_id": {"$in": list(source_ids)}})
return {"$and": clauses} if len(clauses) > 1 else clauses[0]
def citation_for(metadata: dict | None) -> str:
"""A short human-readable citation for a retrieved chunk, built from the source
provenance now persisted in chunk metadata (see services.knowledge.ingest). Prefers a
crawled page's URL/title, then the source name, then the source URI. Empty string when
no provenance is available (legacy chunks ingested before provenance was recorded), so
callers can omit the citation rather than print a blank one."""
m = metadata or {}
page_url = m.get("page_url")
title = m.get("page_title") or m.get("source_name")
if page_url:
return f"{title}{page_url}" if title and title != page_url else str(page_url)
name = m.get("source_name")
uri = m.get("source_uri")
if name and uri:
return f"{name}{uri}"
return str(name or uri or "")
# Client + collection handles are cached process-wide: PersistentClient construction
# and get_or_create_collection cost ~9s cold / ~10ms warm each (measured), and were
# previously paid on every store call.
_CLIENT_CACHE: dict[str, object] = {}
_COL_CACHE: dict[tuple[str, str], object] = {}
# Per-collection write version, bumped on every upsert/delete. The BM25 cache stamps the
# version it was built at and rebuilds when the collection changes, so a lexical index is
# never served stale after an ingest/delete - and never rebuilt while the corpus is unchanged.
_COL_VERSION: dict[tuple[str, str], int] = {}
# Cached lexical index per (collection-key, where-clause): {key: (version, bm25, ids, by_id)}.
# Building it (a full corpus scan + BM25 tokenization) previously ran on EVERY hybrid query
# and on the event loop; now it is built once per corpus version and reused (see hybrid_query,
# which itself runs off the loop via services.knowledge.search).
_BM25_CACHE: dict[tuple, tuple] = {}
# Max chunks pulled into the lexical corpus. Bounds the one-time build cost + cache memory;
# lexical matches in chunks beyond this cap are invisible (rare - needs a huge single project).
_CORPUS_CAP = 5000
def _bump_version(key: tuple[str, str]) -> None:
_COL_VERSION[key] = _COL_VERSION.get(key, 0) + 1
# --- Backend-agnostic hybrid search --------------------------------------------------------
# The lexical (BM25) index build + the RRF fusion are identical regardless of which vector
# backend supplies the dense hits and the corpus scan, so they live here as free functions
# operating on any store that exposes `_key`, `query_where`, and `_get_documents`. Both
# ChromaStore and PgVectorStore delegate to them (DRY + a single caching path).
def _build_lexical_index(store, where: dict, corpus_cap: int) -> tuple:
"""Cached (bm25, ids, by_id) lexical index for `store`'s collection + where-clause. The
corpus scan and BM25 tokenization are the expensive parts of a hybrid query; caching them
per corpus-version turns every subsequent hybrid query into a cheap `get_scores` (rebuilt
only after an ingest/delete bumps the version). by_id keeps chunk text + metadata so a
BM25-only hit still carries its content."""
from forge.knowledge.hybrid import build_bm25
version = _COL_VERSION.get(store._key, 0)
cache_key = (store._key, json.dumps(where, sort_keys=True))
cached = _BM25_CACHE.get(cache_key)
if cached is not None and cached[0] == version:
return cached[1], cached[2], cached[3]
corpus = store._get_documents(where, limit=corpus_cap)
built = build_bm25([(h.id, h.text) for h in corpus])
bm25, ids = built if built else (None, [])
by_id = {h.id: h for h in corpus}
_BM25_CACHE[cache_key] = (version, bm25, ids, by_id)
return bm25, ids, by_id
def _hybrid_fuse(store, *, embedding, query: str, where: dict, top_k: int,
candidate_pool: int | None, corpus_cap: int) -> list[Hit]:
"""Fuse dense (vector) and lexical (BM25) ranking via RRF over `store`'s where-scoped
corpus. Degrades to vector-only when BM25 is unavailable or the corpus has nothing to
match. The returned `score` is the fused rank normalized to (0, 1] (NOT cosine); each Hit
ALSO carries `vector_score`, the underlying dense cosine, so a caller's cosine floor
thresholds the right scale."""
from forge.knowledge.hybrid import bm25_scores, rrf_fuse
pool = candidate_pool or max(top_k * 5, 20)
vec_hits = store.query_where(embedding=embedding, where=where, top_k=pool)
vec_cos = {h.id: h.score for h in vec_hits} # dense cosine per chunk id
bm25, ids, by_id = _build_lexical_index(store, where, corpus_cap)
bm25_ids = bm25_scores((bm25, ids), query)
if not bm25_ids:
return [replace(h, vector_score=h.score) for h in vec_hits[:top_k]]
fused = rrf_fuse([h.id for h in vec_hits], bm25_ids)
by_id = dict(by_id)
by_id.update({h.id: h for h in vec_hits}) # prefer the vector hit's text/metadata
max_score = max(fused.values()) or 1.0
ranked = sorted(fused, key=lambda i: fused[i], reverse=True)[:top_k]
return [
Hit(id=by_id[i].id, text=by_id[i].text,
score=round(fused[i] / max_score, 4), metadata=by_id[i].metadata,
vector_score=vec_cos.get(i))
for i in ranked if i in by_id
]
def _client_for(path: str):
client = _CLIENT_CACHE.get(path)
if client is None:
import chromadb
from chromadb.config import Settings as ChromaSettings
client = chromadb.PersistentClient(
path=path, settings=ChromaSettings(anonymized_telemetry=False, allow_reset=True)
)
_CLIENT_CACHE[path] = client
return client
class ChromaStore:
def __init__(self, path: str | None = None, collection: str = "forge_kb") -> None:
path = path or settings.chroma_path
# Collection is keyed by embedder dimension (e.g. forge_kb_256 / forge_kb_1536)
# so different embedders never collide on a fixed-dim collection.
col = _COL_CACHE.get((path, collection))
if col is None:
col = _client_for(path).get_or_create_collection(collection, metadata={"hnsw:space": "cosine"})
_COL_CACHE[(path, collection)] = col
self._client = _CLIENT_CACHE[path]
self._col = col
self._key = (path, collection)
def upsert(self, *, ids, embeddings, documents, metadatas) -> None:
if not ids:
return
self._col.upsert(ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas)
_bump_version(self._key) # invalidate any cached lexical index for this collection
def query(self, *, embedding, tenant_id, project_id, top_k=5, source_ids=None) -> list[Hit]:
return self.query_where(
embedding=embedding, where=_where(tenant_id, project_id, source_ids), top_k=top_k
)
def query_where(self, *, embedding, where: dict, top_k: int = 5) -> list[Hit]:
res = self._col.query(query_embeddings=[embedding], n_results=top_k, where=where)
hits: list[Hit] = []
ids = (res.get("ids") or [[]])[0]
docs = (res.get("documents") or [[]])[0]
metas = (res.get("metadatas") or [[]])[0]
dists = (res.get("distances") or [[]])[0]
for i, _id in enumerate(ids):
dist = dists[i] if i < len(dists) else 0.0
hits.append(Hit(id=_id, text=docs[i], score=1.0 - float(dist), metadata=metas[i] or {}))
return hits
def _get_documents(self, where: dict, limit: int | None = None) -> list[Hit]:
"""All stored chunks matching `where` (no vector query) - the corpus a lexical
index is built over. score is 0.0 (unranked); `limit` caps the scan."""
try:
res = self._col.get(where=where, limit=limit) if limit else self._col.get(where=where)
except Exception: # noqa: BLE001 - collection empty / not ready
return []
ids = res.get("ids") or []
docs = res.get("documents") or []
metas = res.get("metadatas") or []
return [
Hit(id=ids[i], text=(docs[i] if i < len(docs) else "") or "",
score=0.0, metadata=(metas[i] if i < len(metas) else {}) or {})
for i in range(len(ids))
]
def hybrid_query(
self, *, embedding, query: str, tenant_id, project_id, top_k=5, source_ids=None,
candidate_pool: int | None = None, corpus_cap: int = _CORPUS_CAP,
) -> list[Hit]:
"""Fuse dense (vector) and lexical (BM25) ranking via RRF, scoped by the SAME
tenant/project/source where-clause as vector search (see module `_hybrid_fuse`)."""
return _hybrid_fuse(
self, embedding=embedding, query=query, where=_where(tenant_id, project_id, source_ids),
top_k=top_k, candidate_pool=candidate_pool, corpus_cap=corpus_cap,
)
def dump(self, where: dict, limit: int | None = None, *, ids: list[str] | None = None) -> dict:
"""Raw rows INCLUDING their embedding vectors - the input to the chunk map's
dimensionality reduction. Returns {ids, documents, metadatas, embeddings} (parallel
lists); embeddings come back as whatever numpy-ish rows Chroma stores. Empty on error.
Pass `ids` to fetch exactly those rows (used to pull in specific retrieved chunks that
fell outside the sampled `limit` window); otherwise fetch up to `limit` rows matching
`where`."""
include = ["embeddings", "documents", "metadatas"]
try:
if ids:
res = self._col.get(ids=ids, include=include)
else:
res = self._col.get(where=where, limit=limit, include=include)
except Exception: # noqa: BLE001 - collection empty / not ready
return {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
return {
"ids": res.get("ids") or [],
"documents": res.get("documents") or [],
"metadatas": res.get("metadatas") or [],
"embeddings": res.get("embeddings") if res.get("embeddings") is not None else [],
}
def delete_ids(self, ids: list[str]) -> None:
if ids:
self._col.delete(ids=ids)
_bump_version(self._key)
def delete_by_source(self, source_id: str, *, tenant_id: str | None = None, project_id: str | None = None) -> None:
clauses: list[dict] = [{"source_id": {"$eq": source_id}}]
if tenant_id:
clauses.append({"tenant_id": {"$eq": tenant_id}})
if project_id:
clauses.append({"project_id": {"$eq": project_id}})
where = {"$and": clauses} if len(clauses) > 1 else clauses[0]
self._col.delete(where=where)
_bump_version(self._key)
def delete_where(self, where: dict) -> None:
"""Delete every chunk matching an arbitrary where-clause (used by project deletion to
sweep a tenant/project's vectors). Public so callers never poke at `._col` directly."""
self._col.delete(where=where)
_bump_version(self._key)
def count(self, tenant_id: str, project_id: str) -> int:
return self.count_where(_where(tenant_id, project_id, None))
def count_where(self, where: dict) -> int:
# include=[] returns ids only (ids always come back) - Chroma otherwise also materializes
# every matching row's documents+metadatas just to be counted.
try:
return len(self._col.get(where=where, include=[]).get("ids", []))
except Exception: # noqa: BLE001
return 0
def list_docs(self, where: dict) -> dict:
"""ids + documents + metadatas (NO embeddings) for `where` - lighter than dump() for
operations that only need chunk text (e.g. exact-duplicate detection)."""
try:
res = self._col.get(where=where, include=["documents", "metadatas"])
except Exception: # noqa: BLE001 - collection empty / not ready
return {"ids": [], "documents": [], "metadatas": []}
return {
"ids": res.get("ids") or [],
"documents": res.get("documents") or [],
"metadatas": res.get("metadatas") or [],
}
def get_texts(self, ids: list[str], where: dict) -> dict:
"""Documents + metadatas for specific `ids`, ADDITIONALLY constrained by `where` - so a
caller-supplied id can't read a row outside its tenant/project. NO embeddings (lighter
than dump()); backs the chunk-map detail panel's on-demand full-text fetch."""
if not ids:
return {"ids": [], "documents": [], "metadatas": []}
try:
res = self._col.get(ids=ids, where=where, include=["documents", "metadatas"])
except Exception: # noqa: BLE001 - collection empty / not ready
return {"ids": [], "documents": [], "metadatas": []}
return {
"ids": res.get("ids") or [],
"documents": res.get("documents") or [],
"metadatas": res.get("metadatas") or [],
}
def ids_where(self, where: dict) -> list[str]:
"""The ids currently stored matching `where` - lets a caller index only the rows
that are actually MISSING (vs. a count comparison that can't detect stale ids)."""
try:
return list(self._col.get(where=where).get("ids", []))
except Exception: # noqa: BLE001 - collection empty / not ready
return []
def make_store(collection: str = "forge_kb"):
"""Return the configured embedding store for `collection`, keyed by embedder dim by the
caller (e.g. forge_kb_384). `settings.vector_backend` selects the backend: "chroma"
(embedded, single-writer) or "pgvector" (Postgres-backed, shared across workers). Both
expose the same interface, so call sites are backend-agnostic."""
if settings.vector_backend == "pgvector":
from forge.knowledge.pgvector_store import PgVectorStore
return PgVectorStore(collection=collection)
return ChromaStore(collection=collection)
+279
View File
@@ -0,0 +1,279 @@
"""FastAPI application factory + lifespan.
Builds our own server on the MIT LangChain/LangGraph framework - never depends on
`langgraph-api` or LangSmith. Lifespan initializes the DB, the durable-execution
checkpointer, and dev seed data.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import threading
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import forge
from forge.config import settings
from forge.db import SessionLocal, init_db
from forge.db.seed import bootstrap, seed_demo_data
from forge.routers import (
agents,
assistant,
audit,
auth,
auth_providers,
channels,
components,
connections,
conversations,
embed,
embed_public,
evals,
handoff,
health,
hooks,
knowledge,
mcp_clients,
mcp_oauth,
mcp_server,
mcp_tokens,
models,
nodes,
oauth,
pricing,
project_run,
projects,
runs,
secrets,
stats,
tool_sets,
tools,
traces,
versions,
workflows,
)
from forge.routers import (
triggers as triggers_router,
)
from forge.util.http import aclose_shared_client
async def _make_checkpointer(stack: AsyncExitStack):
"""Durable-execution checkpointer. Selected by FORGE_CHECKPOINT_BACKEND:
- "postgres": durable + shared across workers (REQUIRED for prod/HITL; audit P2).
- "memory": ephemeral (tests / throwaway).
- "sqlite" (default): local file; fine for single-worker dev, lost on restart."""
backend = (settings.checkpoint_backend or "sqlite").lower()
if backend == "memory" or settings.checkpoint_db == "memory":
from langgraph.checkpoint.memory import InMemorySaver
return InMemorySaver()
if backend == "postgres":
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
except ImportError as e: # pragma: no cover - optional extra
raise RuntimeError(
"FORGE_CHECKPOINT_BACKEND=postgres needs langgraph-checkpoint-postgres "
"(pip install -e '.[postgres]')."
) from e
dsn = settings.checkpoint_postgres_url or settings.database_url
# LangGraph wants a plain libpq DSN, not the SQLAlchemy +asyncpg/+psycopg form.
for prefix in ("+asyncpg", "+psycopg", "+psycopg2"):
dsn = dsn.replace(prefix, "")
cp = await stack.enter_async_context(AsyncPostgresSaver.from_conn_string(dsn))
try:
await cp.setup()
except Exception: # noqa: BLE001 - setup is idempotent
pass
return cp
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
cp = await stack.enter_async_context(AsyncSqliteSaver.from_conn_string(settings.checkpoint_db))
try:
await cp.setup()
except Exception: # noqa: BLE001 - setup is idempotent; ignore "already exists"
pass
return cp
async def _reaper_loop(app: FastAPI) -> None:
"""Periodically reap runs stuck in queued/running (never streamed, or driver died) so
they can't linger forever (audit F3)."""
from forge.services.runs import RunService
log = logging.getLogger("forge.reaper")
run_service = RunService(checkpointer=app.state.checkpointer, store=app.state.store)
while True:
try:
await asyncio.sleep(300)
await run_service.reap_stale_runs()
except asyncio.CancelledError:
break
except Exception: # noqa: BLE001 - keep the reaper alive across failures
log.exception("reaper tick failed")
async def _retention_loop() -> None:
"""Purge traces/spans/runs past each project's retention horizon and audit logs past the
workspace horizon, on a timer (finding e). Leader-only (like the reaper) so a multi-replica
deployment purges once. No-op unless a retention window is configured."""
from forge.services.retention import RetentionService
log = logging.getLogger("forge.retention")
interval = max(60, int(settings.retention_interval_seconds or 3600))
while True:
try:
await asyncio.sleep(interval)
await RetentionService.purge_expired()
except asyncio.CancelledError:
break
except Exception: # noqa: BLE001 - keep the retention loop alive across failures
log.exception("retention tick failed")
async def _scheduler_loop(app: FastAPI) -> None:
"""Fire due `schedule` triggers once a minute. Single-worker in-process scheduler;
for multi-worker prod, move to arq/Redis (FORGE_REDIS_URL) so it runs once globally."""
from forge.services.dispatch import run_due_app_events, run_due_schedules
from forge.services.runs import RunService
log = logging.getLogger("forge.scheduler")
run_service = RunService(checkpointer=app.state.checkpointer, store=app.state.store)
while True:
try:
await asyncio.sleep(60)
await run_due_schedules(run_service)
await run_due_app_events(run_service)
except asyncio.CancelledError:
break
except Exception: # noqa: BLE001 - keep the scheduler alive across failures
log.exception("scheduler tick failed")
def _preload_heavy_modules() -> None:
"""Import the slow modules off the critical path. First import of langchain_openai
/ chromadb costs ~22s / ~9s on this machine (AV scanning); doing it in a daemon
thread at startup means the first real run doesn't pay it."""
import importlib
for mod in ("langchain_openai", "chromadb", "langchain.chat_models"):
try:
importlib.import_module(mod)
except Exception: # noqa: BLE001 - optional providers may be missing
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
# Install the IPv4-first DNS resolver before anything opens a connection, so outbound calls
# (LLM providers, REST tools, DB, redis) never pay the multi-second AAAA-lookup stall.
if settings.prefer_ipv4_egress:
from forge.util.netfix import install_prefer_ipv4_dns
install_prefer_ipv4_dns()
settings.ensure_dirs()
problems = settings.validate_production()
if problems:
# Refuse to serve a misconfigured production install (default secrets, auth off,
# SSRF guard off, SQLite, non-durable checkpointer, unsandboxed code). Set the
# flagged env vars before deploying. Enforced for every non-dev environment (S6).
raise RuntimeError("Unsafe production configuration:\n - " + "\n - ".join(problems))
for warn in settings.startup_warnings():
logging.getLogger("forge.config").warning("INSECURE CONFIG: %s", warn)
await init_db()
threading.Thread(target=_preload_heavy_modules, name="forge-preload", daemon=True).start()
app.state.exit_stack = AsyncExitStack()
app.state.checkpointer = await _make_checkpointer(app.state.exit_stack)
app.state.store = None
async with SessionLocal() as session:
tenant_id = await bootstrap(session)
if settings.seed_demo:
await seed_demo_data(session, tenant_id)
app.state.tenant_id = tenant_id
from forge.routers.pricing import load_pricing_overrides
await load_pricing_overrides(session)
if settings.otel_enabled:
from forge.tracing import otel
otel.configure()
bg_tasks: list[asyncio.Task] = []
# The scheduler must run on EXACTLY ONE instance (else every replica double-fires).
# `enable_scheduler` turns it on; `scheduler_leader` elects the single instance by env
# so you can ship one image everywhere (audit P3).
if settings.enable_scheduler and settings.scheduler_leader:
bg_tasks.append(asyncio.create_task(_scheduler_loop(app), name="forge-scheduler"))
# The reaper is safe to run everywhere (idempotent), but one instance is enough.
if settings.scheduler_leader:
bg_tasks.append(asyncio.create_task(_reaper_loop(app), name="forge-reaper"))
# Data-retention purge (leader-only): ages out traces/spans/runs + audit logs (finding e).
if settings.enable_retention and settings.scheduler_leader:
bg_tasks.append(asyncio.create_task(_retention_loop(), name="forge-retention"))
yield
for t in bg_tasks:
t.cancel()
for t in bg_tasks:
with contextlib.suppress(asyncio.CancelledError):
await t
from forge.util.tasks import drain
await drain()
with contextlib.suppress(Exception):
from forge.tools.mcp import close_all
await close_all()
with contextlib.suppress(Exception):
from forge.queue import close_pool
await close_pool()
await aclose_shared_client()
await app.state.exit_stack.aclose()
def create_app() -> FastAPI:
app = FastAPI(
title="Forge API",
version=forge.__version__,
description="Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents.",
lifespan=lifespan,
)
# Host-header allow-list (defense-in-depth against Host-header attacks). Empty => any (dev).
if settings.trusted_hosts:
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Coarse per-IP request ceiling (api_rate_limit_per_minute) as a blunt DoS guard;
# complements the per-surface limits. Health/SSE exempt (finding a).
if settings.enable_global_rate_limit:
from forge.util.ratelimit import GlobalRateLimitMiddleware
app.add_middleware(GlobalRateLimitMiddleware)
# Audit all successful mutations (pure ASGI; safe with SSE streams).
from forge.audit_middleware import AuditMiddleware
app.add_middleware(AuditMiddleware)
for r in (
health.router, auth.router, auth.team_router, auth.workspace_router, auth.apikeys_router,
audit.router, oauth.router, hooks.router,
models.router, nodes.router, projects.router, workflows.router, runs.router, project_run.router,
tool_sets.router, tools.router, components.router, embed.router, embed_public.router, auth_providers.router, connections.router, secrets.router, agents.router,
knowledge.router, knowledge.qa_router, traces.router, conversations.router, assistant.router, stats.router,
triggers_router.router, channels.router, channels.public, handoff.router, evals.router,
pricing.router, mcp_oauth.router, mcp_server.router, mcp_tokens.router, mcp_clients.router, versions.router,
):
app.include_router(r)
return app
app = create_app()
+95
View File
@@ -0,0 +1,95 @@
"""Canonical chat-model catalog - the SINGLE source of truth for the model picker.
The frontend dropdown is served from here (`GET /v1/models`), and the built-in chat-model
rates in `forge.tracing.pricing` are derived from the same list. That means a model can only
appear in the UI if the backend also knows how to run and price it - the two can't drift, so
cost tracking never silently reports $0 for something a user actually selected.
Add a chat model = add one `ModelInfo` row here (with its price). Non-chat priced models
(embeddings) and any priced-but-not-offered models live in `pricing.py`'s extras.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ModelInfo:
id: str # provider-prefixed id passed to init_chat_model, e.g. "openai:gpt-4o-mini"
name: str # short display name
provider: str # display label: OpenAI | Anthropic | Google | Local
ctx: str # context window (display only)
tools: bool # supports tool/function calling
vision: bool # accepts image input
input_per_1m: float # USD / 1M input tokens
output_per_1m: float # USD / 1M output tokens
@property
def bare(self) -> str:
"""Model id without the provider prefix - the key pricing matches on."""
return self.id.split(":", 1)[1] if ":" in self.id else self.id
# Ordered cheap -> expensive within each provider so the fast/cheap picks surface first.
CHAT_MODELS: list[ModelInfo] = [
# OpenAI
ModelInfo("openai:gpt-4.1-nano", "gpt-4.1-nano", "OpenAI", "1M", True, True, 0.1, 0.4),
ModelInfo("openai:gpt-4o-mini", "gpt-4o-mini", "OpenAI", "128k", True, True, 0.15, 0.6),
ModelInfo("openai:gpt-4.1-mini", "gpt-4.1-mini", "OpenAI", "1M", True, True, 0.4, 1.6),
ModelInfo("openai:gpt-4o", "gpt-4o", "OpenAI", "128k", True, True, 2.5, 10.0),
# Anthropic
ModelInfo("anthropic:claude-3-5-haiku-latest", "claude-3-5-haiku", "Anthropic", "200k", True, False, 0.8, 4.0),
ModelInfo("anthropic:claude-haiku-4-5", "claude-haiku-4-5", "Anthropic", "200k", True, True, 1.0, 5.0),
ModelInfo("anthropic:claude-3-5-sonnet-latest", "claude-3-5-sonnet", "Anthropic", "200k", True, True, 3.0, 15.0),
ModelInfo("anthropic:claude-sonnet-4-6", "claude-sonnet-4-6", "Anthropic", "200k", True, True, 3.0, 15.0),
# Google
ModelInfo("google_genai:gemini-1.5-flash", "gemini-1.5-flash", "Google", "1M", True, True, 0.075, 0.3),
ModelInfo("google_genai:gemini-2.5-flash", "gemini-2.5-flash", "Google", "1M", True, True, 0.3, 2.5),
# Offline / test - runs with no provider credentials; never priced (see catalog_prices).
ModelInfo("fake:echo", "fake (offline test)", "Local", "-", True, False, 0.0, 0.0),
]
def catalog_prices() -> dict[str, tuple[float, float]]:
"""Bare-name -> (input, output) rates for the catalog's real (non-fake) chat models.
Merged into `pricing.PRICING` so the picker and the cost engine share one rate table."""
return {
m.bare: (m.input_per_1m, m.output_per_1m)
for m in CHAT_MODELS
if not m.id.startswith("fake")
}
@dataclass(frozen=True)
class EmbeddingModel:
id: str # ref stored in rag_defaults.embedding_model, e.g. "fastembed:BAAI/bge-small-en-v1.5"
name: str
provider: str # Local | OpenAI
dim: int # vector dimension (a Chroma collection is fixed-dim)
billed: bool # True => billed per token at ingest and on every query
default: bool = False # the embedder used when a project leaves it unset
@dataclass(frozen=True)
class RerankerModel:
id: str # cross-encoder id, e.g. "Xenova/ms-marco-MiniLM-L-6-v2"
name: str
note: str # short size/quality hint for the picker
default: bool = False
# Embedding models offered in the picker. `default` must match embeddings._DEFAULT_FASTEMBED;
# billed models must be priced in pricing.py (both guarded by test_model_catalog).
EMBEDDING_MODELS: list[EmbeddingModel] = [
EmbeddingModel("fastembed:BAAI/bge-small-en-v1.5", "bge-small", "Local", 384, False, default=True),
EmbeddingModel("fastembed:BAAI/bge-base-en-v1.5", "bge-base", "Local", 768, False),
EmbeddingModel("openai:text-embedding-3-small", "OpenAI 3-small", "OpenAI", 1536, True),
EmbeddingModel("openai:text-embedding-3-large", "OpenAI 3-large", "OpenAI", 3072, True),
]
# Cross-encoder rerankers (local, CPU, offline). `default` must match rerank.DEFAULT_RERANKER.
RERANKER_MODELS: list[RerankerModel] = [
RerankerModel("Xenova/ms-marco-MiniLM-L-6-v2", "MiniLM-L6", "small, CPU-fast", default=True),
RerankerModel("BAAI/bge-reranker-base", "bge-reranker-base", "heavier, more accurate"),
]
+42
View File
@@ -0,0 +1,42 @@
"""ORM models."""
from forge.models.entities import (
Agent,
AuditLog,
AuthProvider,
Channel,
Component,
Dataset,
EntityVersion,
HandoffRequest,
KbSource,
McpClient,
Memory,
ModelPrice,
OAuthClient,
Project,
QaPair,
Run,
Secret,
Span,
Tenant,
Thread,
Tool,
ToolSet,
ToolSetMember,
Trace,
Trigger,
User,
Workflow,
)
# Eval history tables live in a separate module (append-isolated from entities.py); imported
# here so they register on Base.metadata for create_all (finding F2).
from forge.models.evals import EvalResult, EvalRun
__all__ = [
"Tenant", "User", "Project", "Workflow", "Thread", "Run", "Trace", "Span",
"Tool", "ToolSet", "ToolSetMember", "AuthProvider", "Secret", "McpClient", "Agent", "KbSource", "QaPair",
"AuditLog", "Trigger", "Channel", "Component", "HandoffRequest", "Dataset", "ModelPrice", "Memory",
"EntityVersion", "EvalRun", "EvalResult", "OAuthClient",
]
+523
View File
@@ -0,0 +1,523 @@
"""ORM entities (Doc 2 §4, focused subset for the current phases).
All leaf tables carry `tenant_id` for multi-tenant scoping (RLS is added with
Postgres in prod; SQLite uses query-level scoping). JSON columns hold the
schema-validated config / canvas / executable documents.
Note: `metadata` is reserved by SQLAlchemy's declarative Base, so the JSON column
is exposed as the `meta` attribute (DB column name "metadata").
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
Float,
ForeignKey,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from forge.db.base import Base, PkTimestamp
class Tenant(PkTimestamp, Base):
__tablename__ = "tenants"
name: Mapped[str] = mapped_column(String(200))
plan: Mapped[str] = mapped_column(String(50), default="free")
region: Mapped[str | None] = mapped_column(String(50), nullable=True)
settings: Mapped[dict] = mapped_column(JSON, default=dict)
class User(PkTimestamp, Base):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
email: Mapped[str] = mapped_column(String(320), index=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(30), default="owner") # owner|admin|editor|viewer|connector
status: Mapped[str] = mapped_column(String(20), default="active")
last_login_at: Mapped[datetime | None] = mapped_column(nullable=True)
class Project(PkTimestamp, Base):
__tablename__ = "projects"
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200), index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
config: Mapped[dict] = mapped_column(JSON, default=dict)
status: Mapped[str] = mapped_column(String(20), default="active") # active|draft
archived: Mapped[bool] = mapped_column(Boolean, default=False)
# Public, safe-to-embed key for the chat widget (Phase 3b/4), indexed for O(1) lookup by
# the public /v1/embed/{key} routes. None until embedding is enabled; the rest of the embed
# settings (enabled, allowed_origins, workflow_id) live in config["embed"].
embed_key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
class Workflow(PkTimestamp, Base):
__tablename__ = "workflows"
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), index=True)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
canvas: Mapped[dict] = mapped_column(JSON, default=dict) # React Flow round-trip (UI-owned)
executable: Mapped[dict] = mapped_column(JSON, default=dict) # compiler input (backend-owned)
status: Mapped[str] = mapped_column(String(20), default="draft")
active_version: Mapped[int] = mapped_column(Integer, default=1)
class Tool(PkTimestamp, Base):
__tablename__ = "tools"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
kind: Mapped[str] = mapped_column(String(20)) # rest_api|graphql|code|mcp|builtin
config: Mapped[dict] = mapped_column(JSON, default=dict)
auth_provider_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_tested: Mapped[str | None] = mapped_column(String(20), nullable=True) # pass|fail|untested
class ToolSet(PkTimestamp, Base):
"""A named, describable group of tools ("tool set"). Tool sets are the unit of
organization in the Tools screen (they render as folders) AND the unit of exposure: an
agent can be granted a whole set (agent config.toolsets) and the MCP server can publish
a set as a GitHub-style toolset. Membership is many-to-many via ToolSetMember, so a tool
may live in several sets (label-style, not a single home folder). `description` is what
an MCP client / the model reads to decide when to enable the set; `is_default` marks a
set that's published by default when a project exposes its tools over MCP."""
__tablename__ = "tool_sets"
__table_args__ = (UniqueConstraint("tenant_id", "project_id", "slug", name="uq_tool_set_slug"),)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
slug: Mapped[str] = mapped_column(String(120), index=True) # url-safe id, unique per project
description: Mapped[str] = mapped_column(Text, default="")
icon: Mapped[str | None] = mapped_column(String(60), nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
# Publish this set (and its enabled tools) over MCP. The MCP surface is EXACTLY the enabled
# tools of exposed sets - there are no loose "directly exposed" tools (GitHub-toolset model).
exposed: Mapped[bool] = mapped_column(Boolean, default=True)
class ToolSetMember(PkTimestamp, Base):
"""Many-to-many membership linking a Tool to a ToolSet (a tool can belong to several
sets). No DB-level cascade (matching the rest of the schema); ToolSetService and
ToolService delete the membership rows explicitly when a set or tool is removed."""
__tablename__ = "tool_set_members"
__table_args__ = (UniqueConstraint("tool_set_id", "tool_id", name="uq_tool_set_member"),)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
tool_set_id: Mapped[str] = mapped_column(String(36), index=True)
tool_id: Mapped[str] = mapped_column(String(36), index=True)
class Component(PkTimestamp, Base):
"""A user-authored UI component (Feature 2 - generative UI): saved HTML + CSS,
declarative button `actions`, and a JSON-Schema for the `props` the agent supplies.
Attached to agents like tools (agent config["components"]); at runtime each becomes a
widget-tool that, when called, emits a `component` stream frame for the client to
render - so the markup never enters the model's token stream, only the props do."""
__tablename__ = "components"
# The name is used verbatim as the LLM tool name → unique per project so two components
# can't shadow each other's widget (audit M2). Enforced on fresh DBs; the router also
# pre-checks for the existing-table case (create_all won't add a constraint after the fact).
__table_args__ = (UniqueConstraint("tenant_id", "project_id", "name", name="uq_component_tenant_project_name"),)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
description: Mapped[str] = mapped_column(Text, default="")
props_schema: Mapped[dict] = mapped_column(JSON, default=dict)
html: Mapped[str] = mapped_column(Text, default="")
css: Mapped[str] = mapped_column(Text, default="")
actions: Mapped[list] = mapped_column(JSON, default=list)
sample_props: Mapped[dict] = mapped_column(JSON, default=dict)
kind: Mapped[str] = mapped_column(String(20), default="html") # html (sandboxed) | declarative (future)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[int] = mapped_column(Integer, default=1)
class Agent(PkTimestamp, Base):
__tablename__ = "agents"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
config: Mapped[dict] = mapped_column(JSON, default=dict) # validated vs forge/nodes/agent
# Creator attribution (denormalized email snapshot for display without a join).
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_by_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
class AuthProvider(PkTimestamp, Base):
__tablename__ = "auth_providers"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
kind: Mapped[str] = mapped_column(String(40))
config: Mapped[dict] = mapped_column(JSON, default=dict)
credentials_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
class Secret(PkTimestamp, Base):
__tablename__ = "secrets"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120), index=True)
kind: Mapped[str] = mapped_column(String(40), default="generic")
encrypted_value: Mapped[bytes] = mapped_column(LargeBinary)
version: Mapped[int] = mapped_column(Integer, default=1)
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
class KbSource(PkTimestamp, Base):
__tablename__ = "kb_sources"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
kind: Mapped[str] = mapped_column(String(20)) # text|url|file|s3|api
name: Mapped[str] = mapped_column(String(300))
folder: Mapped[str] = mapped_column(String(200), default="", server_default="") # "" = unfiled
uri: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|processing|ready|error
chunks: Mapped[int] = mapped_column(Integer, default=0)
embedding_model: Mapped[str | None] = mapped_column(String(120), nullable=True)
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
@property
def chunking_strategy(self) -> str | None:
"""Chunking strategy used at ingest (recursive|section|sentence); lives in meta."""
return (self.meta or {}).get("chunk_strategy")
@property
def chunk_size(self) -> int | None:
"""Target chunk size (chars) used at ingest; lives in meta, None until first ingest."""
v = (self.meta or {}).get("chunk_size")
return int(v) if v is not None else None
@property
def chunk_overlap(self) -> int | None:
"""Chunk overlap (chars) used at ingest; lives in meta, None until first ingest."""
v = (self.meta or {}).get("chunk_overlap")
return int(v) if v is not None else None
class QaPair(PkTimestamp, Base):
__tablename__ = "qa_pairs"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
question: Mapped[str] = mapped_column(Text)
answer: Mapped[str] = mapped_column(Text)
kind: Mapped[str] = mapped_column(String(30), default="faq") # faq|error_workaround
tags: Mapped[list] = mapped_column(JSON, default=list)
q_embedding: Mapped[list] = mapped_column(JSON, default=list)
upvotes: Mapped[int] = mapped_column(Integer, default=0)
class McpClient(PkTimestamp, Base):
__tablename__ = "mcp_clients"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
transport: Mapped[str] = mapped_column(String(20), default="http")
url: Mapped[str | None] = mapped_column(String(500), nullable=True)
command: Mapped[str | None] = mapped_column(String(500), nullable=True)
args: Mapped[dict] = mapped_column(JSON, default=dict)
headers_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
disabled_tools: Mapped[list] = mapped_column(JSON, default=list) # remote tool names toggled off in the External MCP tab
class Thread(PkTimestamp, Base):
__tablename__ = "threads"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
user_external_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
lg_thread_id: Mapped[str] = mapped_column(String(100)) # passed to LangGraph configurable.thread_id
title: Mapped[str | None] = mapped_column(String(300), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active")
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
class Run(PkTimestamp, Base):
__tablename__ = "runs"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
thread_id: Mapped[str] = mapped_column(String(36), index=True)
# Where this run originated, for the Traces conversation view. Set at create_run by each
# caller: playground|api|embed|channel_email|webhook|schedule (assistant runs
# have no Run row). Copied onto the Trace at finalize.
source: Mapped[str] = mapped_column(String(40), default="playground")
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|running|interrupted|done|error
input: Mapped[dict] = mapped_column(JSON, default=dict)
output: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
class Trace(PkTimestamp, Base):
__tablename__ = "traces"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
run_id: Mapped[str] = mapped_column(String(36), index=True)
thread_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
name: Mapped[str] = mapped_column(String(300))
status: Mapped[str] = mapped_column(String(20), default="running")
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
# --- Conversation view (Traces): one Trace = one turn; group by thread_id for a session. ---
# Origin of the turn (copied from Run.source; assistant turns = "assistant").
# Indexed: the Traces filter facets run a DISTINCT over this column.
source: Mapped[str] = mapped_column(String(40), default="playground", index=True)
# Display + filter label: "System" for playground/test/assistant, else the end user's name,
# else "Unknown user". Denormalized so the conversation list is a single Trace query.
actor: Mapped[str] = mapped_column(String(300), default="System", index=True)
# Stable end-user id (disambiguates same-named users); None for anonymous / system.
end_user_id: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
# This turn's user message and the AI's response, captured from live state at finalize.
user_message: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_response: Mapped[str | None] = mapped_column(Text, nullable=True)
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
class Trigger(PkTimestamp, Base):
"""An event-driven entry point synced from a workflow's trigger nodes. The
dispatcher (webhook route / scheduler / inbound email / chat) fires runs from these."""
__tablename__ = "triggers"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
node_id: Mapped[str] = mapped_column(String(64))
kind: Mapped[str] = mapped_column(String(20)) # webhook_in|schedule|email_in|app_event
key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # webhook URL key
config: Mapped[dict] = mapped_column(JSON, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_fired_at: Mapped[datetime | None] = mapped_column(nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active")
# Runtime state (e.g. app_event dedupe cursor / seen ids); NOT synced from the node.
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
class Channel(PkTimestamp, Base):
"""An email deployment surface that feeds a workflow.
`config` holds SMTP/IMAP settings and secret refs. `key` is the public,
unguessable id used in inbound endpoint URLs."""
__tablename__ = "channels"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
type: Mapped[str] = mapped_column(String(20)) # email
name: Mapped[str] = mapped_column(String(120))
key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
config: Mapped[dict] = mapped_column(JSON, default=dict)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class HandoffRequest(PkTimestamp, Base):
"""A conversation escalated to a human (live-agent handoff). The run is paused at a
`handoff` interrupt; an agent replies via the inbox, which resumes the run and pushes
the answer back over the originating channel."""
__tablename__ = "handoff_requests"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
run_id: Mapped[str] = mapped_column(String(36), index=True)
thread_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
channel_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
customer: Mapped[str | None] = mapped_column(String(300), nullable=True) # email / display name
customer_message: Mapped[str | None] = mapped_column(Text, nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="open") # open|answered|closed
agent_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
reply_context: Mapped[dict] = mapped_column(JSON, default=dict)
class Memory(PkTimestamp, Base):
"""A long-term memory an agent stored - facts that should persist across threads
(vs. the per-thread checkpointer). Recalled by semantic search; scoped per project
(+ optional `scope` for per-user/per-conversation memory)."""
__tablename__ = "memories"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
scope: Mapped[str] = mapped_column(String(120), default="default", index=True)
text: Mapped[str] = mapped_column(Text)
kind: Mapped[str] = mapped_column(String(30), default="note")
class ModelPrice(PkTimestamp, Base):
"""Admin-editable per-model pricing override (USD per 1M tokens). Overlays the
built-in defaults in tracing/pricing.py so rates can be corrected without a deploy."""
__tablename__ = "model_prices"
model: Mapped[str] = mapped_column(String(120), unique=True, index=True)
input_per_1m: Mapped[float] = mapped_column(Float, default=0.0)
output_per_1m: Mapped[float] = mapped_column(Float, default=0.0)
class Dataset(PkTimestamp, Base):
"""An evaluation dataset: inputs + expected outputs run against a workflow to score
quality and catch regressions before publish."""
__tablename__ = "datasets"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
name: Mapped[str] = mapped_column(String(200))
score_mode: Mapped[str] = mapped_column(String(20), default="contains") # contains|exact|regex|judge
items: Mapped[list] = mapped_column(JSON, default=list) # [{input, expected}]
last_pass_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
class AuditLog(PkTimestamp, Base):
"""Append-only audit trail: who did what, when (Doc 2 §12). Written for auth
events, secret reads, and create/update/delete of every resource. Never updated
or deleted in normal operation."""
__tablename__ = "audit_logs"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
actor_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
actor_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
action: Mapped[str] = mapped_column(String(80), index=True) # e.g. auth.login, secret.read, workflow.delete
resource_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
resource_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="ok") # ok|denied|error
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
class EntityVersion(PkTimestamp, Base):
"""Immutable point-in-time snapshot of a versionable entity's config, captured on each
save so a user can view history and restore a prior version. Generic across entity types
(workflow|agent|tool|component|auth_provider|kb_source|project) - the `snapshot` JSON holds
the entity's restorable fields. Retention is pruned to the configured version_history_limit
per (entity_type, entity_id). See forge.services.versions."""
__tablename__ = "entity_versions"
__table_args__ = (
UniqueConstraint("entity_type", "entity_id", "version_no", name="uq_entity_version"),
)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
entity_type: Mapped[str] = mapped_column(String(40), index=True)
entity_id: Mapped[str] = mapped_column(String(36), index=True)
version_no: Mapped[int] = mapped_column(Integer, default=1)
label: Mapped[str | None] = mapped_column(String(300), nullable=True) # entity name at snapshot time / note
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
author_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
author_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
class Span(PkTimestamp, Base):
__tablename__ = "spans"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
trace_id: Mapped[str] = mapped_column(String(36), index=True)
parent_span_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
name: Mapped[str] = mapped_column(String(300))
kind: Mapped[str] = mapped_column(String(20)) # llm|tool|chain|retriever|agent|node|subagent
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
input: Mapped[dict | None] = mapped_column(JSON, nullable=True)
output: Mapped[dict | None] = mapped_column(JSON, nullable=True)
model: Mapped[str | None] = mapped_column(String(120), nullable=True)
input_tokens: Mapped[int] = mapped_column(Integer, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, default=0)
cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
attributes: Mapped[dict] = mapped_column(JSON, default=dict)
# --- Platform-hardening tables (finding h/j). All new: dev create_all builds them; prod needs
# an Alembic migration + the tenant-isolation policies in infra/postgres_rls.sql. -------------
class ApiKey(PkTimestamp, Base):
"""A hashed, revocable API key for server-to-server callers (finding h). Scoped to a tenant
with a fixed role; the plaintext is shown ONCE at creation and only its SHA-256 hash is
stored. Presented as `Authorization: Bearer <key>` and resolved in get_current_user. This
is per-tenant and per-role, unlike the single static FORGE_SERVICE_API_TOKEN."""
__tablename__ = "api_keys"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
name: Mapped[str] = mapped_column(String(120))
# Non-secret lookup hint (the key's leading chars), safe to display in the console.
prefix: Mapped[str] = mapped_column(String(16), index=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) # sha256 hex of the full key
role: Mapped[str] = mapped_column(String(30), default="editor") # owner|admin|editor|viewer
status: Mapped[str] = mapped_column(String(20), default="active") # active|revoked
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(nullable=True) # optional hard expiry
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
# Personal access token (MCP): when `user_id` is set this row is a per-user PAT (plaintext
# prefix forge_pat_, minted by ApiKeyService.create_personal) used to authenticate an
# individual over a project's MCP server AS an end_user, optionally scoped to one `project_id`.
# A PAT is deliberately NOT a general-API principal - get_current_user only honors forge_sk_.
user_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
class ProjectMember(PkTimestamp, Base):
"""Per-project role grant (finding h). Present => the member's EFFECTIVE role on that project
is the higher of this and their tenant-wide role; absent => the tenant-wide role applies, so
this is purely additive and backward-compatible."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
user_id: Mapped[str] = mapped_column(String(36), index=True)
role: Mapped[str] = mapped_column(String(30), default="viewer") # owner|admin|editor|viewer
class OAuthClient(PkTimestamp, Base):
"""A dynamically-registered OAuth 2.1 client (RFC 7591) for the MCP authorization server.
Public clients (no secret), identified by `client_id` with an exact-match redirect_uri
allow-list. Registered open / pre-auth (any MCP client may register); the USER identity is
established later at the authorize step. Global (no tenant_id) - it is a client registry, not
tenant data. Only used when settings.mcp_oauth_enabled is on."""
__tablename__ = "oauth_clients"
client_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
client_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
redirect_uris: Mapped[list] = mapped_column(JSON, default=list)
class UserSecurity(PkTimestamp, Base):
"""Per-user auth state kept OFF the `users` row (finding j) so it can be added without an
ALTER of an existing table: email-verification flag + optional TOTP MFA. NOTE: `totp_secret`
is stored as a base32 string; encrypt it at rest (secret store / KMS) before enabling MFA on
a shared production install."""
__tablename__ = "user_security"
__table_args__ = (UniqueConstraint("user_id", name="uq_user_security_user"),)
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
user_id: Mapped[str] = mapped_column(String(36), index=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
email_verified_at: Mapped[datetime | None] = mapped_column(nullable=True)
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+61
View File
@@ -0,0 +1,61 @@
"""Eval history entities (finding F2): persisted eval runs + per-item results.
`Dataset` (in entities.py) only ever kept `last_pass_rate` - a single scalar with no
history and no per-example detail, so you couldn't diff two runs or see WHICH example
regressed. These two append-only tables record every eval run (timestamped, with a
rollup + a reference to the previous pass rate for the regression gate) and every
item's outcome (produced answer, pass/fail, numeric score, and the per-assertion
breakdown). Kept in a separate module so the append is isolated from the shared
entities.py (concurrent-edit safety); imported by forge.models so create_all registers them.
"""
from __future__ import annotations
from sqlalchemy import JSON, Boolean, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from forge.db.base import Base, PkTimestamp
class EvalRun(PkTimestamp, Base):
"""One execution of a dataset against its workflow. `created_at` (from PkTimestamp) is
the timestamp; `prev_pass_rate`/`regressed` back the publish-time regression gate."""
__tablename__ = "eval_runs"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
project_id: Mapped[str] = mapped_column(String(36), index=True)
dataset_id: Mapped[str] = mapped_column(String(36), index=True)
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
score_mode: Mapped[str] = mapped_column(String(20), default="contains")
status: Mapped[str] = mapped_column(String(20), default="done") # done|error
total: Mapped[int] = mapped_column(Integer, default=0)
passed: Mapped[int] = mapped_column(Integer, default=0)
pass_rate: Mapped[float] = mapped_column(Float, default=0.0)
# The dataset's pass rate BEFORE this run (the baseline the gate compares against); None
# on the first ever run. `regressed` is set when the gate is on and the rate dropped.
prev_pass_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
regressed: Mapped[bool] = mapped_column(Boolean, default=False)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict) # truncation, gate config, counts
class EvalResult(PkTimestamp, Base):
"""One dataset item's outcome within an EvalRun - the produced answer plus its score,
so history + per-example diffing exist. `checks` holds the per-assertion breakdown when
the item used an assertion list."""
__tablename__ = "eval_results"
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
eval_run_id: Mapped[str] = mapped_column(String(36), index=True)
item_index: Mapped[int] = mapped_column(Integer, default=0)
input: Mapped[str | None] = mapped_column(Text, nullable=True)
expected: Mapped[str | None] = mapped_column(Text, nullable=True)
answer: Mapped[str | None] = mapped_column(Text, nullable=True)
passed: Mapped[bool] = mapped_column(Boolean, default=False)
score: Mapped[float | None] = mapped_column(Float, nullable=True)
# scored | run_failed | unavailable | error - so an inconclusive judge/embedding item is
# distinguishable from a genuine fail (a misleading 0% is exactly what finding F4 fixes).
status: Mapped[str] = mapped_column(String(20), default="scored")
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
checks: Mapped[list] = mapped_column(JSON, default=list)
+21
View File
@@ -0,0 +1,21 @@
"""Built-in node factories. Importing this package registers every node type.
Registered: start, end, router, agent, deep_agent, llm, classifier, transform,
human_input, handoff, webhook_out, emit_event, tool_call, retrieval,
subworkflow, parallel_fanout, join, loop, and the triggers (webhook_in, schedule,
email_in, app_event).
"""
from forge.nodes import ( # noqa: F401 (import => register)
agent_node,
data,
flow,
llm_node,
rag,
triggers,
)
def load_builtin_nodes() -> None:
"""No-op: importing `forge.nodes` already registered the built-ins."""
return None
+421
View File
@@ -0,0 +1,421 @@
"""Agent node (`create_agent`) and Deep Agent node (`create_deep_agent`).
Doc 2 §9. Both produce a compiled LangGraph graph used as a node inside the
workflow. The embedded agent does NOT carry its own checkpointer/store - the
top-level workflow graph owns durability, and LangGraph propagates it to subgraphs
at runtime (avoids nested-checkpointer conflicts and makes HITL interrupts bubble up).
"""
from __future__ import annotations
import logging
from typing import Any
from forge.engine.context import CompileContext
from forge.engine.middleware_compiler import build_middleware
from forge.engine.models import resolve_model
from forge.engine.registry import NodeSpec, Port, register
log = logging.getLogger("forge.agent")
def _dedup_tools_by_name(tools: list) -> list:
"""Bind each tool NAME to the model at most once. `resolve_tool_ids` already de-dups by id (a
tool shared across several sets is ONE record → one id, sent once), but tool names are NOT
unique per project and the final list mixes sources (tools + knowledge + MCP + components), so
two entries can still collide by name - which providers reject (OpenAI errors on a duplicate
function name). Keep the first occurrence; drop later name-collisions with a warning."""
seen: set[str] = set()
out: list = []
for t in tools:
name = getattr(t, "name", None)
if name is not None and name in seen:
log.warning("agent tool name %r appears more than once; keeping the first, dropping the rest", name)
continue
if name is not None:
seen.add(name)
out.append(t)
return out
# Forge's default output style: every agent reply renders as GitHub-Flavored Markdown
# (Feature 1 - structured responses). It lives in the system prompt, so it costs ~nothing
# per turn (and is cached by the Anthropic prompt-caching middleware). Opt out with
# config output_style="plain"; auto-skipped for structured-output agents (they emit JSON).
OUTPUT_STYLE = (
"Format every reply as GitHub-Flavored Markdown so it renders cleanly: short "
"paragraphs; `##`/`###` headings for sections; `-` or numbered lists; GFM tables for "
"comparisons or structured data; fenced code blocks with a language hint for code; and "
"**bold** for key terms. Keep the structure minimal - only as much as the answer needs "
"- and never output raw HTML."
)
# When UI components are attached, structured data should be shown via a component (table/
# card/form), NOT a markdown table - so this variant drops the "GFM tables for structured
# data" clause to avoid competing with the widgets (audit B1).
OUTPUT_STYLE_WITH_COMPONENTS = (
"Format every reply as GitHub-Flavored Markdown so it renders cleanly: short paragraphs; "
"`##`/`###` headings; `-` or numbered lists; fenced code blocks with a language hint; and "
"**bold** for key terms. For structured data (tables, cards, forms), prefer the available "
"UI components over a markdown table. Keep structure minimal and never output raw HTML."
)
# Steer the agent to RENDER a fitting component instead of restating its data as prose, and to
# POSITION it correctly: calling a component tool returns a placeholder marker that the agent
# copies into its reply where the widget belongs - so the component is interleaved with the text
# in its natural place (mid-answer, after a heading, at the end) rather than always pinned to the
# top (which is what happens if placement is left to tool-call order). The last sentence is
# load-bearing: it makes clear components only PRESENT data, so the agent keeps using its
# retrieval/other tools normally - without it, the component guidance was competing with
# knowledge/FAQ search and the agent skipped it (audit Priority B + the KB regression). Only
# appended when config["components"] is non-empty.
COMPONENT_STYLE = (
"You have UI components available as tools (their names match the components). If a "
"component fits the data you want to show (a table, card, form, …), you MUST call that "
"component tool with the data as its props INSTEAD of writing the same data as prose or a "
"markdown table. The tool returns a placeholder marker like [[forge:component:ID]]; copy that "
"marker verbatim into your reply at the exact position where the component should appear. You "
"control the order - write text before and after the marker so the component lands in its "
"natural place in the answer (in the middle, after a heading, or at the end), exactly as it "
"would read in a normal reply. Never restate the component's contents as text. This governs "
"only how you PRESENT data - keep using your other tools (search the knowledge base, look up "
"FAQs, call APIs) normally to GET the information you need."
)
def _build_prompt(config: dict) -> str | None:
# Static system prompt + Forge's default Markdown output style (+ component guidance when
# components are attached). Dynamic prompts compile to a middleware (added later).
base = (config.get("system_prompt") or "").strip()
structured = (config.get("response_format") or {}).get("mode") == "structured"
if structured or config.get("output_style") == "plain":
return base or None
has_components = bool(config.get("components"))
style = OUTPUT_STYLE_WITH_COMPONENTS if has_components else OUTPUT_STYLE
parts = ([base] if base else []) + [style]
if has_components:
parts.append(COMPONENT_STYLE)
return "\n\n".join(parts).strip()
def _build_response_format(config: dict) -> Any:
rf = config.get("response_format")
if not rf or rf.get("mode") != "structured":
return None
# create_agent accepts a raw JSON-schema dict (auto provider/tool strategy).
return rf.get("schema")
# deepagents' built-in `task`-tool guidance is a ~536-token essay injected into EVERY supervisor
# call. This concise replacement keeps the essential guidance at ~1/8th the tokens (a real per-turn
# cost lever for supervisors, which re-read their prompt every turn). Override via SubAgentMiddleware.
_TASK_TOOL_PROMPT = (
"Use the `task` tool to delegate a self-contained subtask to one of the specialist subagents "
"listed in its schema. Prefer it for multi-step work you can hand off wholesale, and dispatch "
"independent subagents in parallel. You receive only each subagent's final result — not its "
"intermediate steps — so give it a clear, complete instruction. Don't delegate trivial one-tool "
"lookups; do those yourself."
)
def build_subagents(subagents_cfg: list[dict], ctx: CompileContext, default_model: Any = None) -> list[dict]:
"""Convert subagent configs to the SubAgent dict shape SubAgentMiddleware expects.
Standalone SubAgentMiddleware (our lean deep_agent, see agent_factory) REQUIRES each subagent to
carry `model` and `tools` - create_deep_agent used to fill those in - and deepagents re-resolves
a *string* model via init_chat_model WITHOUT our injected provider key, so we always set a
RESOLVED model object (defaulting to the parent deep_agent's model) and a concrete tools list.
`system_prompt` is always set (deepagents wraps it). `workflow_ref` subagents are the still-
unwired subworkflow phase.
"""
out: list[dict] = []
for sa in subagents_cfg or []:
if "workflow_ref" in sa:
continue # TODO(phase: subworkflow): wrap compiled workflow as CompiledSubAgent
name = sa["name"]
spec: dict[str, Any] = {
"name": name,
"description": sa.get("description", ""),
"system_prompt": sa.get("system_prompt") or sa.get("description") or f"You are the {name} agent.",
"tools": _dedup_tools_by_name(ctx.tools_for(ctx.resolve_tool_ids(sa.get("tools"), sa.get("toolsets")))),
"model": resolve_model(sa["model"], ctx) if sa.get("model") else (default_model or resolve_model(None, ctx)),
}
if sa.get("middleware"):
spec["middleware"] = build_middleware(sa["middleware"], ctx)
out.append(spec)
return out
def _maybe_add_prompt_caching(stack: list[dict], config: dict, ctx: CompileContext) -> list[dict]:
"""Prepend Anthropic prompt-caching middleware for Anthropic-model agents (cost lever),
unless already present or disabled. Best-effort: only when langchain-anthropic exists."""
import importlib.util
from forge.config import settings
if not settings.default_anthropic_prompt_caching:
return stack
model_ref = config.get("model") or getattr(ctx, "default_model", "") or ""
if not (isinstance(model_ref, str) and model_ref.startswith("anthropic")):
return stack
if any((m or {}).get("type") == "anthropic_prompt_caching" for m in stack):
return stack
if importlib.util.find_spec("langchain_anthropic") is None:
return stack
return [{"type": "anthropic_prompt_caching", "config": {}}, *stack]
def _clamp(value, n: int = 300):
"""Bound an end_user value's size before it enters the prompt (avoid bloat/abuse)."""
if isinstance(value, str):
return value[:n]
if isinstance(value, list):
return [_clamp(v, n) for v in value[:20]]
if isinstance(value, dict):
return {str(k)[:60]: _clamp(v, n) for k, v in list(value.items())[:20]}
return value
def _end_user_block(end_user: dict) -> str:
"""A generic identity-awareness block. Only a whitelisted, size-clamped subset of the
(untrusted-shaped) end_user is embedded, re-serialized so the JSON stays well-formed
(audit L4). The withhold-restriction sentence is added ONLY when the user actually carries
roles/entitlements - an unscoped prohibition with no entitlement list made the model
over-refuse general KB/FAQ answers ("I don't have that information") (audit Priority A)."""
import json as _json
safe = {
k: _clamp(end_user[k])
for k in ("id", "display_name", "email", "roles", "entitlements", "attributes")
if end_user.get(k) not in (None, "", [], {})
}
if not safe:
return ""
eu = _json.dumps(safe, default=str, ensure_ascii=False)
line = (
"[END USER] You are assisting this authenticated end user, provided by the host "
f"application - treat it as authoritative: {eu}."
)
if safe.get("roles") or safe.get("entitlements"):
line += (
" General product, FAQ, and knowledge-base information is available to everyone - "
"always answer it. Only withhold data that is specific to OTHER users or accounts "
"this user is not entitled to see or act on."
)
return line
def _dynamic_field_middleware(config: dict, ctx: CompileContext, base_prompt: str | None) -> list:
"""Compile the agent node's `dynamic_model` / `dynamic_prompt` blocks - both exposed in the
UI but previously unwired (audit F9) - into middleware.
- dynamic_model reuses the proven `dynamic_model_by_state` builder (switch model by a
state expression).
- dynamic_prompt renders the FIRST matching rule's prompt (with `{{state.*}}` tokens) as
the system prompt per model call, falling back to the node's static prompt when no rule
matches - so enabling it with no matching rule is behavior-neutral."""
extra: list = []
dm = config.get("dynamic_model") or {}
if dm.get("enabled") and dm.get("rules"):
extra += build_middleware(
[{"type": "dynamic_model_by_state", "config": {"rules": dm["rules"], "default": dm.get("default")}}],
ctx,
)
dp = config.get("dynamic_prompt") or {}
rules = dp.get("rules") or []
if dp.get("enabled") and rules:
from langchain.agents.middleware import dynamic_prompt as _dynamic_prompt
from forge.auth_providers.templates import render_template
from forge.engine.expressions import ExpressionError, eval_truthy
fallback = base_prompt or ""
@_dynamic_prompt
def _prompt(request): # type: ignore[no-untyped-def]
state = dict(getattr(request, "state", {}) or {})
for r in rules:
text = r.get("prompt")
if not text:
continue
when = r.get("when")
try:
if not when or eval_truthy(when, state):
rendered = render_template(text, {"state": state}) if isinstance(text, str) else text
return str(rendered) if rendered is not None else fallback
except ExpressionError:
continue
return fallback
extra.append(_prompt)
return extra
def _common_kwargs(config: dict, ctx: CompileContext) -> dict:
tools = list(ctx.tools_for(ctx.resolve_tool_ids(config.get("tools"), config.get("toolsets"))))
# Built-in knowledge access (RAG / Q&A) attached straight to the agent via its
# `knowledge` config - no separate Tool row needed (see tools/builtin.py).
if config.get("knowledge"):
from forge.tools.builtin import build_knowledge_capability_tools
tools += build_knowledge_capability_tools(config["knowledge"], ctx)
# Agent-scoped MCP server access: attach each selected server's enabled tools
# (pre-loaded by the runtime assembler into ctx.mcp_tools_by_client; native MCP tools).
for cid in config.get("mcp_servers", []) or []:
tools += (getattr(ctx, "mcp_tools_by_client", None) or {}).get(cid, [])
# User-defined UI components exposed as widget-tools (Feature 2): the agent "renders"
# one by calling it; the client draws the saved template from the props it passes.
tools += list(ctx.components_for(config.get("components", [])))
# Final guard: exactly one function name per model call, whatever the source mix.
tools = _dedup_tools_by_name(tools)
stack = (ctx.project_default_mw or []) + (config.get("middleware") or [])
stack = _maybe_add_prompt_caching(stack, config, ctx)
middleware = build_middleware(stack, ctx)
model = resolve_model(config.get("model"), ctx, config.get("model_params"))
common: dict[str, Any] = {"model": model, "tools": tools, "middleware": middleware}
prompt = _build_prompt(config)
# Identity awareness: if the run acts for an end user, append a generic context block so
# the agent knows who it's helping and to stay within their entitlements. Appended last,
# so the (cacheable) instructions prefix is unchanged; only this per-user suffix varies.
end_user = getattr(ctx, "end_user", None)
if end_user:
eu_block = _end_user_block(end_user)
if eu_block:
prompt = f"{prompt}\n\n{eu_block}" if prompt else eu_block
if prompt:
common["system_prompt"] = prompt
# Wire the dynamic_model / dynamic_prompt config blocks (append after the static stack so
# a matching rule overrides the base at call time). base_prompt = the fully-built static
# prompt so a non-matching dynamic_prompt run reproduces the static behavior exactly.
dynamic_mw = _dynamic_field_middleware(config, ctx, prompt)
if dynamic_mw:
common["middleware"] = list(middleware) + dynamic_mw
rf = _build_response_format(config)
if rf is not None:
common["response_format"] = rf
if config.get("name"):
common["name"] = config["name"]
return common
def _resolve_config(config: dict, ctx: CompileContext) -> dict:
"""If the node mirrors a saved agent (`agent_ref`), the live preset drives it - so
edits in the Agents tab take effect without re-saving the workflow. Falls back to the
node's own (snapshot) config when the preset is missing/unresolved."""
ref = config.get("agent_ref")
if ref:
preset = (getattr(ctx, "agent_presets", None) or {}).get(ref)
if preset:
return dict(preset)
return config
def agent_factory(config: dict, ctx: CompileContext):
config = _resolve_config(config, ctx)
common = _common_kwargs(config, ctx)
from langchain.agents import create_agent
if config.get("flavor") == "deep_agent":
# A deep_agent is `create_agent` + only the deepagents harness pieces the operator opts
# into (planning / filesystem / subagents). We do NOT use `create_deep_agent`, which always
# bundles the FULL harness (write_todos + filesystem + a general-purpose subagent + a large
# base prompt) - that overhead made a simple lookup ~6x the tokens of a lean agent. Each
# piece is added only when its config toggle is on, exactly like attaching a tool.
try:
from deepagents import SubAgentMiddleware
from deepagents.backends import StateBackend
except ImportError as e: # pragma: no cover - deepagents is a core dep
raise ImportError(
"deep_agent flavor needs `deepagents` (a core dependency - reinstall with "
"`pip install -e .`)."
) from e
# Backend for the filesystem / subagent middleware: a sandbox if configured, else the
# default in-memory (thread-scoped) state backend - which carries NO shell `execute` tool.
backend = ctx.sandbox_backend_for(config.get("sandbox", {}) or {}) or StateBackend()
middleware = list(common.get("middleware") or [])
if config.get("planning"): # write_todos planner (off by default - pure token overhead)
from langchain.agents.middleware import TodoListMiddleware
middleware.append(TodoListMiddleware())
fs = config.get("filesystem") or {}
if fs.get("enabled") or (fs.get("backend") and fs.get("backend") != "none"):
from deepagents.middleware import FilesystemMiddleware
middleware.append(FilesystemMiddleware(backend=backend))
# Deep-agent skills (agent-skills source paths): wire as SkillsMiddleware - the SAME
# middleware create_deep_agent uses - so deep_agent NODES keep the skills capability under
# the lean create_agent path (backend-backed, so skill files load from the configured store).
if config.get("skills"):
from deepagents.middleware.skills import SkillsMiddleware
middleware.append(SkillsMiddleware(backend=backend, sources=list(config["skills"])))
subagents = build_subagents(config.get("subagents", []), ctx, default_model=common["model"])
if subagents:
middleware.append(SubAgentMiddleware(backend=backend, subagents=subagents, system_prompt=_TASK_TOOL_PROMPT))
kwargs: dict[str, Any] = dict(common)
kwargs["middleware"] = middleware
return create_agent(**kwargs)
return create_agent(**common)
def _summary(config: dict) -> list[str]:
model = config.get("model", "-")
n_tools = len(config.get("tools", []) or [])
n_mw = len([m for m in (config.get("middleware") or []) if m.get("enabled", True)])
flavor = config.get("flavor", "agent")
line2 = f"{n_tools} tools · {n_mw} middleware"
n_comp = len(config.get("components", []) or [])
if n_comp:
line2 += f" · {n_comp} widget{'s' if n_comp != 1 else ''}"
k = config.get("knowledge") or {}
kbits = [name for name, key in (("RAG", "rag"), ("Q&A", "qa")) if (k.get(key) or {}).get("enabled")]
if kbits:
line2 += " · KB " + "+".join(kbits)
n_mcp = len(config.get("mcp_servers", []) or [])
if n_mcp:
line2 += f" · MCP {n_mcp}"
if flavor == "deep_agent":
line2 += f" · subagents {len(config.get('subagents', []) or [])}"
return [str(model), line2]
_ports = (
[Port(id="in", io_type="messages", direction="in")],
[Port(id="out", io_type="messages", direction="out")],
)
register(
NodeSpec(
type="agent",
schema_id="forge/nodes/agent",
input_ports=_ports[0],
output_ports=_ports[1],
factory=agent_factory,
allows_cycle=True,
category="agents",
label="Agent",
description="ReAct tool loop",
summarize=_summary,
)
)
register(
NodeSpec(
type="deep_agent",
schema_id="forge/nodes/agent",
input_ports=_ports[0],
output_ports=_ports[1],
factory=agent_factory,
allows_cycle=True,
category="agents",
label="Deep Agent",
description="Planning + subagents harness",
summarize=_summary,
)
)
+261
View File
@@ -0,0 +1,261 @@
"""Data / integration nodes: transform, human_input, webhook_out, emit_event.
Convention: data nodes read from an optional `input_key` (else the whole state)
and write to `output_key` (which MUST be a declared state field, else LangGraph
rejects the update). `human_input` writes the decision into `messages`.
(`tool_call` and the RAG node land next: tool_call needs per-user context
plumbing for auth'd tools; retrieval needs the Chroma store.)
"""
from __future__ import annotations
import logging
from typing import Any
import jmespath
from forge.auth_providers.templates import render_value
from forge.engine.context import CompileContext
from forge.engine.registry import NodeSpec, Port, register
log = logging.getLogger("forge.data")
def _jq_transform(expr: str, input_key: str | None, output_key: str):
"""Build a jq-powered transform node. jq is optional; if the `jq` package isn't installed
we raise a clear ValueError WHEN THE NODE RUNS rather than silently falling back to
JMESPath (which speaks a different language and would quietly produce wrong data) - audit
F7. Compile succeeds so the rest of the workflow still previews."""
try:
import jq as _jq
except ImportError:
def _unavailable(state: dict) -> dict:
raise ValueError(
"transform engine 'jq' requires the `jq` package, which is not installed. "
"Install it (pip install jq) or switch this transform's engine to 'jmespath'."
)
return _unavailable
try:
program = _jq.compile(expr) # a malformed program surfaces here, at compile time
except Exception as e: # noqa: BLE001 - re-raise as a clear config error
raise ValueError(f"Invalid jq expression {expr!r}: {e}") from e
def _node(state: dict) -> dict:
src = state.get(input_key) if input_key else dict(state)
try:
result: Any = program.input(src).first()
except Exception as e: # noqa: BLE001 - a runtime jq failure -> None, but log it
log.warning("transform jq %r failed: %s: %s", expr, type(e).__name__, e)
result = None
return {output_key: result}
return _node
def transform_factory(cfg: dict, ctx: CompileContext):
expr = cfg["expression"]
engine = cfg.get("engine", "jmespath")
input_key = cfg.get("input_key")
output_key = cfg.get("output_key", "data")
if engine == "jq":
return _jq_transform(expr, input_key, output_key)
def _node(state: dict) -> dict:
src = state.get(input_key) if input_key else dict(state)
try:
result: Any = jmespath.search(expr, src)
except jmespath.exceptions.JMESPathError as e:
# Previously swallowed to None silently, which hid typo'd expressions; log it so a
# broken transform is traceable in the run log (audit F7).
log.warning("transform jmespath %r failed: %s: %s", expr, type(e).__name__, e)
result = None
return {output_key: result}
return _node
def human_input_factory(cfg: dict, ctx: CompileContext):
from langchain_core.messages import HumanMessage
from langgraph.types import interrupt
from forge.services.runs import HITL_APPROVAL_TIMEOUT_SECONDS
prompt = cfg["prompt"]
decisions = cfg.get("allowed_decisions", ["approve", "reject"])
schema = cfg.get("schema")
# When set, also write the decision string to this state key so a downstream router
# can branch on it (approve → continue, reject → end). The key must be declared in
# workflow state (the canvas auto-declares node-written keys).
output_key = cfg.get("output_key")
# Deadline surfaced on the interrupt so operators/UI see how long the approval waits before
# the reaper expires it (audit C). Per-node override, else the global HITL timeout (0 = none).
timeout_seconds = cfg.get("timeout_seconds") or HITL_APPROVAL_TIMEOUT_SECONDS or None
timeout_default = cfg.get("timeout_default")
if timeout_default not in decisions:
timeout_default = None
def _node(state: dict) -> dict:
# Pauses the run; resumed via Command(resume=value). Node re-runs from the
# top on resume, so the side effect (writing the decision) is placed after.
decision = interrupt({
"prompt": prompt, "allowed_decisions": decisions, "schema": schema,
"timeout_seconds": timeout_seconds, "timeout_default": timeout_default,
})
out: dict[str, Any] = {"messages": [HumanMessage(content=f"[human decision] {decision}")]}
if output_key:
# Coerce a free-text resume value to one of allowed_decisions for the ROUTING key so a
# Router keyed on approve/reject matches even on a direct API resume (audit C). The
# transcript message above keeps the human's raw wording; only the routed value is
# normalized. Structured (dict) input is left as-is.
routed: Any = decision
if isinstance(decision, str) and decisions:
from forge.services.handoff import coerce_to_allowed_decision
routed = coerce_to_allowed_decision(decision, list(decisions))
out[output_key] = str(routed)
return out
return _node
def handoff_factory(cfg: dict, ctx: CompileContext):
"""Live-agent handoff: pause the run (interrupt) and hand the conversation to a
human. The channel creates a HandoffRequest; when a human replies via the agent
inbox, the run resumes with their text, which becomes the assistant's reply."""
from langchain_core.messages import AIMessage
from langgraph.types import interrupt
reason = cfg.get("reason", "Escalated to a human agent.")
def _node(state: dict) -> dict:
reply = interrupt({"handoff": True, "reason": reason, "ack_message": cfg.get("ack_message")})
return {"messages": [AIMessage(content=str(reply))]}
return _node
def webhook_out_factory(cfg: dict, ctx: CompileContext):
method = cfg["method"]
url_t = cfg["url"]
provider_id = cfg.get("auth_provider_id")
output_key = cfg.get("output_key", "webhook_result")
body_t = cfg.get("body")
headers_t = cfg.get("headers", {})
async def _node(state: dict) -> dict:
from forge.util.http import shared_async_client
from forge.util.ssrf import validate_url
vars = {"state": dict(state)}
url = render_value(url_t, vars)
body = render_value(body_t, vars) if body_t else None
headers = render_value(dict(headers_t), vars)
params: dict[str, str] = {}
cookies: dict[str, str] = {}
if provider_id and ctx.auth_resolver:
auth = await ctx.auth_resolver.resolve(
tenant_id=ctx.tenant_id, project_id=ctx.project_id, provider_id=provider_id, context={}
)
headers.update(auth.headers)
params.update(auth.params)
cookies.update(auth.cookies)
await validate_url(url, getattr(ctx, "egress_policy", None))
c = shared_async_client()
r = await c.request(method, url, headers=headers, params=params or None, json=body, cookies=cookies or None, timeout=30)
try:
out: Any = r.json()
except Exception: # noqa: BLE001
out = r.text
return {output_key: out}
return _node
def tool_call_factory(cfg: dict, ctx: CompileContext):
tool_id = cfg["tool_id"]
input_mapping = cfg.get("input_mapping", {}) or {}
output_key = cfg.get("output_key", "tool_result")
async def _node(state: dict, config=None) -> dict:
# Invoke the SAME materialized tool an agent would use, passing the run config so
# the call is traced (the tracer is a callback on config) and so REST/GraphQL/
# code/sql/mcp all go through one path with one error contract.
tool = ctx.tool_registry.get(tool_id)
if tool is None:
return {output_key: {"error": f"tool {tool_id} not available"}}
args: dict[str, Any] = {}
for k, expr in input_mapping.items():
try:
args[k] = jmespath.search(expr, dict(state)) if isinstance(expr, str) else expr
except jmespath.exceptions.JMESPathError:
args[k] = expr
try:
out = await tool.ainvoke(args, config)
except Exception as e: # noqa: BLE001 - surface tool failure as a structured result
out = {"error": str(e)}
return {output_key: out}
return _node
def emit_event_factory(cfg: dict, ctx: CompileContext):
channel = cfg["channel"]
payload_t = cfg.get("payload", {})
def _node(state: dict) -> dict:
try:
from langgraph.config import get_stream_writer
get_stream_writer()({"channel": channel, "payload": render_value(payload_t, {"state": dict(state)})})
except Exception: # noqa: BLE001 - no active stream writer (e.g. ainvoke)
pass
return {}
return _node
_io_any = ([Port(id="in", io_type="any", direction="in")], [Port(id="out", io_type="any", direction="out")])
register(NodeSpec(
type="transform", schema_id="forge/nodes/transform",
input_ports=[Port(id="in", io_type="json", direction="in")],
output_ports=[Port(id="out", io_type="json", direction="out")],
factory=transform_factory, category="model_tools", label="Transform", description="JMESPath data map",
summarize=lambda c: [f"{c.get('engine', 'jmespath')} · → {c.get('output_key', 'data')}"],
))
register(NodeSpec(
type="human_input", schema_id="forge/nodes/human_input",
input_ports=_io_any[0], output_ports=_io_any[1],
factory=human_input_factory, category="human", label="Human Input", description="HITL pause via interrupt",
summarize=lambda c: [c.get("prompt", "")[:40], " · ".join(c.get("allowed_decisions", ["approve", "reject"]))],
))
register(NodeSpec(
type="tool_call", schema_id="forge/nodes/tool_call",
input_ports=[Port(id="in", io_type="json", direction="in")],
output_ports=[Port(id="out", io_type="json", direction="out")],
factory=tool_call_factory, category="model_tools", label="Tool Call", description="Run a specific tool",
summarize=lambda c: [str(c.get("tool_id", "-")), f"{c.get('output_key', 'tool_result')}"],
))
register(NodeSpec(
type="webhook_out", schema_id="forge/nodes/webhook_out",
input_ports=[Port(id="in", io_type="json", direction="in")],
output_ports=[Port(id="out", io_type="json", direction="out")],
factory=webhook_out_factory, category="integrations", label="Webhook", description="Call external URL",
summarize=lambda c: [f"{c.get('method', 'POST')} {str(c.get('url', ''))[:32]}"],
))
register(NodeSpec(
type="handoff", schema_id="forge/nodes/handoff",
input_ports=_io_any[0], output_ports=_io_any[1],
factory=handoff_factory, category="human", label="Human Handoff",
description="Escalate the conversation to a human agent (pauses until they reply).",
summarize=lambda c: [c.get("reason", "human handoff")[:40]],
))
register(NodeSpec(
type="emit_event", schema_id="forge/nodes/emit_event",
input_ports=_io_any[0], output_ports=_io_any[1],
factory=emit_event_factory, category="integrations", label="Emit Event", description="Push custom SSE frame",
summarize=lambda c: [f"channel · {c.get('channel', '')}"],
))
+441
View File
@@ -0,0 +1,441 @@
"""Flow-control nodes: start, end, router.
- `start` / `end` are passthrough markers; the compiler wires START -> entry_node
and every `end` node -> END.
- `router` evaluates a sandboxed expression over state and routes to a case target.
Its outgoing routing comes from `config.cases`/`config.default` (the compiler adds
conditional edges), so labeled canvas edges out of a router are ignored at compile.
"""
from __future__ import annotations
import logging
from typing import Any
from langgraph.graph import END
from forge.engine.context import CompileContext
from forge.engine.expressions import ExpressionError, eval_expression, eval_truthy
from forge.engine.registry import NodeSpec, Port, register
log = logging.getLogger("forge.flow")
# Keys a parallel_fanout stamps onto each child's Send payload (its INPUT state) so a child /
# a downstream join can reassemble results in a STABLE order despite the nondeterministic
# superstep completion order (audit F2). They ride only in the Send payload (verified: extra
# Send-payload keys reach the child but never enter global workflow state), so they need not be
# declared in `state` and never leak to the run output.
FANOUT_INDEX_KEY = "_fanout_index"
FANOUT_TOTAL_KEY = "_fanout_total"
def _passthrough(state: dict) -> dict:
return {}
def start_factory(config: dict, ctx: CompileContext):
return _passthrough
def end_factory(config: dict, ctx: CompileContext):
return _passthrough
def make_router_path(config: dict):
"""Build a LangGraph path function: state -> target node id (or END).
With `multi: true`, a list-valued expression (e.g. a multi-label classifier's
output) routes to EVERY matching case target - LangGraph runs them in parallel
within one superstep, which is how one question with several intents reaches
several specialists at once.
"""
expr = config["expression"]
cases = config.get("cases", {}) or {}
default = config.get("default")
multi = bool(config.get("multi", False))
def _one(val: Any) -> str | None:
# match by string key first (JSON object keys are strings), then raw value
target = cases.get(str(val))
if target is None and not isinstance(val, str) and val in cases:
target = cases[val]
return target
def _path(state: dict) -> Any:
try:
val = eval_expression(expr, dict(state or {}))
except ExpressionError as e:
# A failing router expression silently ending the run is a debugging nightmare;
# log it (and fall through to the default/END) so it's traceable (audit F10).
log.warning("router expression %r failed: %s", expr, e)
val = None
if multi:
vals = list(val) if isinstance(val, (list, tuple, set)) else ([val] if val is not None else [])
targets: list[str] = []
for v in vals:
t = _one(v)
if t and t not in targets:
targets.append(t)
if targets:
return targets
if not default:
log.warning("router %r matched no case and has no default; ending the run", expr)
return default if default else END
target = _one(val)
if target is None:
if not default:
log.warning("router %r value %r matched no case and has no default; ending the run", expr, val)
target = default
return target if target else END
return _path
def router_factory(config: dict, ctx: CompileContext):
# The node itself is a passthrough; routing is added as conditional edges.
return _passthrough
def subworkflow_factory(config: dict, ctx: CompileContext):
"""Compile a referenced workflow as a nested graph node (reusable component).
The sub-graph shares the parent's tool/agent/auth context but carries NO checkpointer
(the top-level workflow owns durability - same rule as embedded agents). Recursion is
broken by tracking in-progress workflow ids on the context.
Key mapping (audit F6): by default parent and child share state keys by name (the child
reads/writes the same `messages` etc.). When `input_mapping` / `output_mapping` are set the
node instead runs the child in ISOLATION and copies only the mapped keys across:
- input_mapping: {parent_state_key: child_state_key} (parent -> child, before the run)
- output_mapping: {child_state_key: parent_state_key} (child -> parent, after the run)
`version`, when given, is honored best-effort: only the project's CURRENT executable per
workflow id is available here, so a mismatch is logged rather than silently ignored."""
import dataclasses
from forge.engine.compiler import compile_workflow
ref = config["workflow_id"]
sub_def = (getattr(ctx, "workflows", {}) or {}).get(ref)
if not sub_def:
def _missing(state: dict) -> dict:
return {}
return _missing
want_version = config.get("version")
if want_version is not None and sub_def.get("version") != want_version:
# The runtime only carries the latest executable per workflow id/name (see
# runtime.make_runtime_ctx), so we can't pin an older version here - surface it
# instead of pretending the request was honored. Full pinning needs a
# version-keyed workflow store (noted for a follow-up).
log.warning(
"subworkflow %r requested version %s but only version %s is available; using it",
ref, want_version, sub_def.get("version"),
)
compiling = getattr(ctx, "compiling", set())
if ref in compiling: # cycle: refuse to recurse
def _cycle(state: dict) -> dict:
return {}
return _cycle
compiling.add(ref)
try:
sub_ctx = dataclasses.replace(ctx, checkpointer=None, store=None)
sub_graph = compile_workflow(sub_def, sub_ctx)
finally:
compiling.discard(ref)
input_mapping = config.get("input_mapping") or {}
output_mapping = config.get("output_mapping") or {}
if not input_mapping and not output_mapping:
# Shared-state fast path (unchanged behavior): LangGraph runs the compiled child as a
# subgraph, sharing state keys by name and bubbling interrupts up for HITL.
return sub_graph
async def _mapped(state: dict, config=None) -> dict:
child_in: dict[str, Any] = {}
for parent_key, child_key in input_mapping.items():
if parent_key in state:
child_in[child_key] = state[parent_key]
child_out = await sub_graph.ainvoke(child_in, config)
out: dict[str, Any] = {}
for child_key, parent_key in output_mapping.items():
if child_key in child_out:
out[parent_key] = child_out[child_key]
return out
return _mapped
def loop_factory(config: dict, ctx: CompileContext):
"""Iterate: increment `_loop_count` and write `_loop` = 'continue'/'done' so a router
can loop the body back here. Stops at `max_iter` or when `condition` is falsy. The
body edge must return to this node (it `allows_cycle`); declare `_loop_count`/`_loop`
in state (the canvas auto-declares node-written keys)."""
max_iter = int(config.get("max_iter", 10))
condition = config.get("condition")
def _node(state: dict) -> dict:
# `_loop_count` is the running firing count; stop once it reaches max_iter. (Counting
# contract is intentionally stable - see test_loop_node_counts_and_stops.)
i = int(state.get("_loop_count", 0)) + 1
cont = i < max_iter
if cont and condition:
try:
cont = eval_truthy(condition, dict(state))
except ExpressionError as e:
# A failing loop condition silently ending the loop is hard to debug; log it.
log.warning("loop condition %r failed: %s", condition, e)
cont = False
return {"_loop_count": i, "_loop": "continue" if cont else "done"}
return _node
def make_fanout_path(config: dict):
"""LangGraph Send-based map: run `child_node` once per item in `state[over]`, with the
item placed at `item_key`. Children aggregate via an `add`-reducer state key.
Each Send payload is also stamped with the item's 0-based input index (`index_key`,
default `_fanout_index`) and the batch size (`_fanout_total`) so a child - or a
downstream join - can restore a STABLE order over the nondeterministic superstep
completion order (audit F2). These extra keys live only in the child's input state and
never enter the shared workflow state, so they need no `state` declaration."""
from langgraph.types import Send
over = config["over"]
child = config["child_node"]
item_key = config["item_key"]
index_key = config.get("index_key") or FANOUT_INDEX_KEY
def _path(state: dict) -> Any:
items = state.get(over) or []
if not items:
# An empty fan-out produces no Sends, so the child (and anything gated on its
# aggregated output) never runs. Log it so an empty `over` isn't a silent
# dead-end the operator can't see (audit F10).
log.warning("parallel_fanout over %r produced no items; no children dispatched", over)
total = len(items)
return [
Send(child, {item_key: item, index_key: i, FANOUT_TOTAL_KEY: total})
for i, item in enumerate(items)
]
return _path
def resilient_fanout_child(fn, *, timeout: float | None = None, isolate: bool = False):
"""Wrap a parallel_fanout child so one item's failure/timeout doesn't abort the whole
superstep (partial-failure isolation) and a slow item can be bounded (per-item timeout).
Only applied when the workflow opts in (error_policy "continue" or the fanout's
on_item_error="skip"/item_timeout_seconds) - the compiler decides. LangGraph control-flow
signals (interrupts / Command bubbling, `GraphBubbleUp`) and cancellation are ALWAYS
re-raised so HITL keeps working; only genuine errors are isolated. A skipped item
contributes no state update ({}). Sync children run inline and can't be preempted, so the
per-item timeout applies to async children / compiled subgraphs only."""
import asyncio
import inspect
from langgraph.errors import GraphBubbleUp
is_runnable = hasattr(fn, "ainvoke")
is_coro = inspect.iscoroutinefunction(fn)
accepts_config = False
if not is_runnable:
try:
accepts_config = len(inspect.signature(fn).parameters) >= 2
except (TypeError, ValueError):
accepts_config = False
async def _invoke(state: dict, config):
if is_runnable:
return await fn.ainvoke(state, config)
if is_coro:
return await (fn(state, config) if accepts_config else fn(state))
# Plain sync node: call inline (JMESPath transforms etc. are trivial). It runs on the
# event loop, so it can't be timed out - documented above.
return fn(state, config) if accepts_config else fn(state)
async def _wrapped(state: dict, config=None) -> dict:
idx = state.get(FANOUT_INDEX_KEY)
try:
if timeout and (is_runnable or is_coro):
return await asyncio.wait_for(_invoke(state, config), timeout)
return await _invoke(state, config)
except GraphBubbleUp:
raise # interrupts / Command bubbling must reach the parent for HITL to work
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - isolate a single item's failure when opted in
if not isolate:
raise
log.warning(
"parallel_fanout child failed for item #%s (%s: %s); skipping it",
idx, type(e).__name__, e,
)
return {}
return _wrapped
def _apply_join_reducer(reducer: str, value: Any) -> Any:
"""Aggregate a fan-in value per the join `reducer`. `value` is the list the fan-out
children accumulated into a state key (via that key's `add` reducer)."""
if not isinstance(value, list):
# A lone/non-list value has nothing to aggregate: concat/first/last are identity, and
# merge on a single dict is identity too.
return value
if reducer == "first":
return value[0] if value else None
if reducer == "last":
return value[-1] if value else None
if reducer == "merge":
merged: dict[str, Any] = {}
for v in value:
if isinstance(v, dict):
merged.update(v)
return merged
# concat (default): flatten one level when children each contributed a list, else the
# already-flat accumulated list is the concatenation.
if value and all(isinstance(v, list) for v in value):
return [x for sub in value for x in sub]
return value
def join_factory(config: dict, ctx: CompileContext):
"""Converge parallel branches.
Default (no `input_key`): a passthrough convergence marker - the fan-out results are
already aggregated by their state key's own `add` reducer, so the node just re-joins the
branches. When `input_key` is set the node ACTIVELY re-aggregates that key per `reducer`
(concat|merge|first|last) and writes the result to `output_key` (default = `input_key`),
so the reducer choice is honored rather than merely advisory (audit F1).
Note: if `output_key` equals a key that uses an `add` reducer, the reduced value would be
appended (not replaced) - point `output_key` at a `last`-reducer field for a clean rewrite.
"""
reducer = config.get("reducer", "concat")
input_key = config.get("input_key")
output_key = config.get("output_key") or input_key
def _node(state: dict) -> dict:
if not input_key:
return {} # convergence marker; aggregation is done by the state-key reducer
return {output_key: _apply_join_reducer(reducer, state.get(input_key))}
return _node
def router_targets(config: dict) -> list[str]:
cases = config.get("cases", {}) or {}
targets = list(cases.values())
if config.get("default"):
targets.append(config["default"])
return sorted(set(targets))
register(
NodeSpec(
type="start",
schema_id="forge/nodes/start",
input_ports=[],
output_ports=[Port(id="out", io_type="control", direction="out")],
factory=start_factory,
category="flow",
label="Start",
description="Entry marker",
summarize=lambda c: [],
)
)
register(
NodeSpec(
type="end",
schema_id="forge/nodes/end",
input_ports=[Port(id="in", io_type="control", direction="in")],
output_ports=[],
factory=end_factory,
category="flow",
label="End",
description="Terminal node",
summarize=lambda c: [],
)
)
register(
NodeSpec(
type="loop",
schema_id="forge/nodes/loop",
input_ports=[Port(id="in", io_type="any", direction="in")],
output_ports=[Port(id="out", io_type="any", direction="out")],
factory=loop_factory,
allows_cycle=True,
category="flow",
label="Loop",
description="Iterate the body until a condition/max-iterations (writes _loop=continue/done).",
summarize=lambda c: [f"max {c.get('max_iter', 10)}", c.get("condition", "")[:32]],
)
)
register(
NodeSpec(
type="parallel_fanout",
schema_id="forge/nodes/parallel_fanout",
input_ports=[Port(id="in", io_type="json", direction="in")],
output_ports=[Port(id="out", io_type="control", direction="out", many=True)],
factory=lambda c, ctx: _passthrough,
category="flow",
label="Parallel Fanout",
description="Map a list: run a child node per item in parallel (Send).",
summarize=lambda c: [f"over {c.get('over', '-')}{c.get('child_node', '-')}"],
)
)
register(
NodeSpec(
type="join",
schema_id="forge/nodes/join",
input_ports=[Port(id="in", io_type="control", direction="in", many=True)],
output_ports=[Port(id="out", io_type="json", direction="out")],
factory=join_factory,
category="flow",
label="Join",
description="Converge parallel branches; optionally re-aggregate a key via the reducer.",
summarize=lambda c: [
f"reducer · {c.get('reducer', 'concat')}"
+ (f" · {c.get('input_key')}{c.get('output_key') or c.get('input_key')}" if c.get("input_key") else "")
],
)
)
register(
NodeSpec(
type="subworkflow",
schema_id="forge/nodes/subworkflow",
input_ports=[Port(id="in", io_type="any", direction="in")],
output_ports=[Port(id="out", io_type="any", direction="out")],
factory=subworkflow_factory,
category="flow",
label="Subworkflow",
description="Run another workflow as a reusable component.",
summarize=lambda c: [f"{c.get('workflow_id', '-')}"],
)
)
register(
NodeSpec(
type="router",
schema_id="forge/nodes/router",
input_ports=[Port(id="in", io_type="any", direction="in")],
output_ports=[Port(id="out", io_type="control", direction="out", many=True)],
factory=router_factory,
category="flow",
label="Router",
description="Conditional branch",
summarize=lambda c: [
f"expression · {c.get('expression', '')}" + (" · multi" if c.get("multi") else ""),
" · ".join(list((c.get('cases') or {}).keys()) + (["default"] if c.get("default") else [])),
],
)
)
+205
View File
@@ -0,0 +1,205 @@
"""`llm` node - a single model call, no tool loop (Doc 2 §7).
Reads the conversation from `messages`, optionally prepends the configured prompt
as a system message, and appends the model's reply. Structured output binds the
response_format schema. The prompt is a TemplateString: `{{state.<key>}}` tokens are
rendered against the current run state (same engine the webhook/emit nodes use), so a
"rewrite this value" prompt sees the actual state instead of the literal placeholder.
Per-run secrets (`ctx.*`) are intentionally NOT exposed to the prompt (LLM-visible).
"""
from __future__ import annotations
from typing import Any
from forge.engine.context import CompileContext
from forge.engine.models import resolve_model
from forge.engine.registry import NodeSpec, Port, register
def llm_factory(config: dict, ctx: CompileContext):
model = resolve_model(config["model"], ctx, config.get("model_params"))
prompt = config.get("prompt")
rf = (config.get("response_format") or {})
structured_schema = rf.get("schema") if rf.get("mode") == "structured" else None
runnable = model.with_structured_output(structured_schema) if structured_schema else model
async def _node(state: dict) -> dict:
from langchain_core.messages import SystemMessage
from forge.auth_providers.templates import render_template
msgs = list(state.get("messages") or [])
# Render {{state.*}} tokens in the prompt against the live run state. A whole-string
# single token can resolve to a non-str value; coerce so SystemMessage content is text.
rendered = render_template(prompt, {"state": dict(state)}) if isinstance(prompt, str) and prompt else prompt
if rendered is not None and not isinstance(rendered, str):
rendered = str(rendered)
input_msgs: list[Any] = ([SystemMessage(content=rendered)] if rendered else []) + msgs
result = await runnable.ainvoke(input_msgs)
if structured_schema:
# Structured result is not a message; surface it on a conventional channel.
return {"structured_response": result}
return {"messages": [result]}
return _node
register(
NodeSpec(
type="llm",
schema_id="forge/nodes/llm",
input_ports=[Port(id="in", io_type="text", direction="in")],
output_ports=[Port(id="out", io_type="text", direction="out")],
factory=llm_factory,
category="model_tools",
label="LLM",
description="Single model call",
summarize=lambda c: [str(c.get("model", "-")), "single call"],
)
)
def classifier_factory(config: dict, ctx: CompileContext):
"""Classify the latest user message into one (or, with `multi_label`, several) of N
labels (structured output) and write the result to a state key (default `intent`)
for a downstream router.
This is the docs' routing pattern: "use structured output for the routing decision,
then add_conditional_edges". With `multi_label: true` the node writes a LIST of
labels - pair it with a router configured `multi: true` to fan out to every matching
branch in parallel (multi-intent questions). A label outside the configured set (or
a model failure) falls back to a naive keyword match, else writes nothing - the
router's default/Else path then handles it.
"""
labels = [str(label) for label in (config.get("labels") or []) if str(label).strip()]
output_key = config.get("output_key", "intent")
instructions = config.get("instructions", "")
multi = bool(config.get("multi_label", False))
# Intent classification is high-volume + low-stakes - default to the provider's
# cheapest model (overridable via config.model) instead of the workflow default.
model_ref = config.get("model")
if not model_ref:
from forge.engine.models import cheap_model_for_credentials
model_ref = cheap_model_for_credentials(getattr(ctx, "provider_credentials", None))
model = resolve_model(model_ref, ctx, config.get("model_params"))
if multi:
schema = {
"title": "Classification",
"type": "object",
"properties": {
"labels": {
"type": "array",
"items": {"type": "string", "enum": labels or ["other"]},
"minItems": 1,
"description": "EVERY label that applies to the user's message.",
}
},
"required": ["labels"],
}
else:
schema = {
"title": "Classification",
"type": "object",
"properties": {"label": {"type": "string", "enum": labels or ["other"]}},
"required": ["label"],
}
def _text_of(message: Any) -> str:
content = message.get("content") if isinstance(message, dict) else getattr(message, "content", "")
return content if isinstance(content, str) else str(content or "")
def _role_of(message: Any) -> str | None:
return message.get("role") if isinstance(message, dict) else getattr(message, "type", None)
def _keyword_fallback(text: str) -> list[str]:
q = text.lower()
return [
label for label in labels
if label.lower() in q or label.lower().replace("_", " ") in q
]
def _recent_context(msgs: list[Any], max_messages: int = 8, max_chars: int = 4000) -> str:
lines: list[str] = []
for m in msgs[-max_messages:]:
role = _role_of(m) or "message"
text = _text_of(m).strip()
if text:
lines.append(f"{role}: {text}")
return "\n".join(lines)[-max_chars:]
async def _node(state: dict) -> dict:
msgs = state.get("messages") or []
query = ""
for m in reversed(msgs):
role = _role_of(m)
content = _text_of(m)
if role in ("human", "user") and content:
query = content
break
if not query or not labels:
return {}
from langchain_core.messages import HumanMessage
task = (
f"Classify the latest user message into EVERY label that applies (one or more) from: {', '.join(labels)}.\n"
if multi else
f"Classify the latest user message into exactly one of these labels: {', '.join(labels)}.\n"
)
# Fold a BOUNDED slice of recent turns into the prompt instead of resending the entire
# message history each call. Classification only needs the latest message plus a little
# context for follow-ups ("what about Delhi?"); an unbounded history makes this node
# progressively slower and pricier as the conversation grows, and duplicated the query
# (restated in the prompt AND resent as messages). One bounded message keeps it flat.
context = _recent_context(list(msgs))
prompt = (
task
+ "Use the recent conversation context to resolve follow-up or elliptical messages "
"(for example, 'what about Delhi?' should inherit the prior topic). "
"Return only the structured label(s).\n"
+ (f"{instructions}\n" if instructions else "")
+ (f"\nRecent conversation:\n{context}\n" if context else "")
+ f"\nLatest user message: {query}"
)
chosen: list[str] = []
try:
# Send the prompt as a HUMAN turn, not a system message: Gemini routes a lone system
# message to system_instruction and then rejects the call with "contents are required"
# (empty contents), and Anthropic requires a leading user turn. A single human message
# carries the whole self-contained classification prompt and works across providers.
res = await model.with_structured_output(schema).ainvoke([HumanMessage(content=prompt)])
if multi:
raw = res.get("labels") if isinstance(res, dict) else getattr(res, "labels", None)
chosen = [str(x) for x in (raw or []) if str(x) in labels]
else:
label = res.get("label") if isinstance(res, dict) else getattr(res, "label", None)
chosen = [label] if label in labels else []
except Exception: # noqa: BLE001 - offline/fake models can't do structured output
chosen = []
if not chosen:
chosen = _keyword_fallback(query) or _keyword_fallback(_recent_context(list(msgs)))
if not chosen:
return {}
return {output_key: chosen if multi else chosen[0]}
return _node
register(
NodeSpec(
type="classifier",
schema_id="forge/nodes/classifier",
input_ports=[Port(id="in", io_type="text", direction="in")],
output_ports=[Port(id="out", io_type="text", direction="out")],
factory=classifier_factory,
category="model_tools",
label="Classifier",
description="Intent classification",
summarize=lambda c: [
f"{c.get('output_key', 'intent')}" + (" · multi" if c.get("multi_label") else ""),
" · ".join((c.get("labels") or [])[:4]) or "no labels",
],
)
)
+194
View File
@@ -0,0 +1,194 @@
"""Knowledge node: retrieval - RAG document search + curated Q&A lookup in one node.
It reads the latest user message from `messages` and appends a SystemMessage with the
retrieved document chunks and/or matching Q&A pairs (grounding for a downstream agent).
Document search (include_docs) and Q&A lookup (include_qa) are toggled independently.
"""
from __future__ import annotations
from typing import Any
from forge.engine.context import CompileContext
from forge.engine.registry import NodeSpec, Port, register
from forge.knowledge.embeddings import DEFAULT_MIN_SCORE, DEFAULT_RERANK_MIN_SCORE
from forge.knowledge.store import citation_for
_KB_TAG = "forge_kb"
def _passes_floor(h: Any, min_score: float, hybrid: bool) -> bool:
"""Cosine-floor a hit on the RIGHT scale. In hybrid mode Hit.score is the fused RANK (top≈1.0),
NOT cosine, so threshold Hit.vector_score (the real cosine) instead; a BM25-only hit has no
cosine (vector_score None) and is kept (a strong exact-term match shouldn't be floored out).
In vector-only mode Hit.score IS the cosine."""
cos = h.vector_score if hybrid else h.score
return cos is None or cos >= min_score
def _kb_removals(msgs: list[Any]) -> list[Any]:
"""RemoveMessage for every prior retrieval system-message (tagged), so KB context is
EPHEMERAL - only the current turn's chunks stay in history instead of accumulating
(which otherwise grows cost every turn on a checkpointed thread)."""
from langchain_core.messages import RemoveMessage
out: list[Any] = []
for m in msgs or []:
ak = m.get("additional_kwargs") if isinstance(m, dict) else (getattr(m, "additional_kwargs", {}) or {})
if ak and ak.get(_KB_TAG):
mid = m.get("id") if isinstance(m, dict) else getattr(m, "id", None)
if mid:
out.append(RemoveMessage(id=mid))
return out
def _last_user_text(msgs: list[Any]) -> str:
for m in reversed(msgs or []):
role = m.get("role") if isinstance(m, dict) else getattr(m, "type", None)
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
if role in ("human", "user", None) and content:
return content if isinstance(content, str) else str(content)
if msgs:
m = msgs[-1]
c = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
return c if isinstance(c, str) else str(c or "")
return ""
def retrieval_factory(cfg: dict, ctx: CompileContext):
top_k = cfg.get("top_k", 5)
# RAG document search is on by default. Turn it off for a Q&A-only retrieval node:
# include_docs=False + include_qa=True makes this node subsume the old qa_lookup.
include_docs = cfg.get("include_docs", True)
# Hybrid = fuse BM25 lexical ranking with vector search (RRF). Opt-in; default vector-only.
hybrid = bool(cfg.get("hybrid", False))
# Rerank = a second-stage local cross-encoder over a larger shortlist, keeping the best
# top_k. Opt-in; big accuracy win at some latency. rerank_top_n sizes the shortlist.
rerank = bool(cfg.get("rerank", False))
rerank_top_n = cfg.get("rerank_top_n")
source_filter = cfg.get("source_filter") or None
# Restrict retrieval to sources in these folders (resolved to source ids at run
# time, so it composes with source_filter and needs no Chroma re-ingest).
folders = cfg.get("folders") or None
# Cosine floor so wildly off-topic queries surface no context (the grounded agent then refuses
# instead of answering from the nearest chunk). Calibrated to the default BGE embedder (~0.6;
# related pairs ~0.75+, unrelated ~0.4-0.5) - lower it for a hosted model on a smaller scale.
min_score = cfg.get("min_score", DEFAULT_MIN_SCORE)
# When rerank is on, Hit.score is the cross-encoder sigmoid (a DIFFERENT scale from cosine), so
# min_score can't apply; this separate floor lets an off-topic reranked query still yield empty.
rerank_min_score = cfg.get("rerank_min_score", DEFAULT_RERANK_MIN_SCORE)
# Optional MMR diversity pass (trade a little relevance for less-redundant top_k).
mmr = bool(cfg.get("mmr", False))
mmr_lambda = cfg.get("mmr_lambda", 0.5)
include_qa = cfg.get("include_qa", False)
qa_threshold = cfg.get("qa_threshold", 0.3)
qa_top_k = cfg.get("qa_top_k", 3)
# Only include Q&A pairs of these kinds/categories (empty = all kinds).
qa_kinds = cfg.get("qa_kinds") or None
# When nothing relevant is found, inject an explicit note so a grounded agent knows
# to say it doesn't have the answer rather than fall back to model world-knowledge.
announce_empty = cfg.get("announce_empty", False)
# When set, also write "yes"/"no" (found anything?) to this state key so a downstream
# router can branch found → agent / not-found → escalation. Optional; key must be
# declared in workflow state (the canvas auto-declares node-written keys).
route_key = cfg.get("route_key")
async def _node(state: dict) -> dict:
from langchain_core.messages import SystemMessage
from forge.db.base import SessionLocal
from forge.services.knowledge import KnowledgeService
query = _last_user_text(state.get("messages") or [])
if not query:
return {route_key: "no"} if route_key else {}
blocks: list[str] = []
async with SessionLocal() as s:
# Embed the query ONCE and reuse the vector for both doc search and Q&A
# scoring. Skip entirely when neither source is enabled, so a node configured
# to do nothing makes no embedding API call.
embedder = qvec = None
if include_docs or include_qa:
try:
embedder = await KnowledgeService.embedder_for_project(s, ctx.tenant_id, ctx.project_id)
qvec = await embedder.aembed_query(query)
except Exception as e: # noqa: BLE001 - embedder unavailable
from forge.util.metrics import incr
incr("retrieval.embedder_unavailable", detail=str(e))
embedder = qvec = None
try:
hits = await KnowledgeService.search(
s, ctx.tenant_id, ctx.project_id, query, top_k=top_k,
source_ids=source_filter, folders=folders, embedder=embedder, embedding=qvec,
hybrid=hybrid, rerank=rerank, rerank_top_n=rerank_top_n, mmr=mmr, mmr_lambda=mmr_lambda,
) if (include_docs and embedder) else []
except Exception: # noqa: BLE001 - store not ready / empty
hits = []
# Apply the relevance floor on the correct scale. Reranked hits use the cross-encoder
# sigmoid floor (rerank_min_score); otherwise the cosine floor (min_score) on the real
# cosine - Hit.vector_score in hybrid mode (Hit.score there is the fused rank, not cosine).
if rerank:
if rerank_min_score is not None:
hits = [h for h in hits if h.score >= rerank_min_score]
elif min_score is not None:
hits = [h for h in hits if _passes_floor(h, min_score, hybrid)]
for i, h in enumerate(hits):
cite = citation_for(h.metadata)
label = f"Doc {i + 1} · {cite}" if cite else f"Doc {i + 1}"
blocks.append(f"[{label}] {h.text}")
if include_qa:
try:
qa = await KnowledgeService.top_qa(
s, ctx.tenant_id, ctx.project_id, query, top_k=qa_top_k, threshold=qa_threshold,
kinds=qa_kinds, embedder=embedder, embedding=qvec,
)
except Exception: # noqa: BLE001
qa = []
for q in qa:
blocks.append(f"[FAQ] Q: {q['question']}\nA: {q['answer']}")
out: dict[str, Any] = {}
if route_key:
out[route_key] = "yes" if blocks else "no"
removals = _kb_removals(state.get("messages") or [])
if not blocks:
if announce_empty:
out["messages"] = [*removals, SystemMessage(
content="KNOWLEDGE BASE: no relevant entries were found for the user's question.",
additional_kwargs={_KB_TAG: True},
)]
elif removals:
out["messages"] = removals
return out
ctxt = "\n\n".join(blocks)
out["messages"] = [*removals, SystemMessage(
content="KNOWLEDGE BASE context for the user's question:\n" + ctxt,
additional_kwargs={_KB_TAG: True},
)]
return out
return _node
def _retrieval_summary(c: dict) -> list[str]:
"""Glanceable canvas lines: which sources this retrieval node pulls from."""
lines: list[str] = []
if c.get("include_docs", True):
flags = ("" if not c.get("hybrid") else " · hybrid") + ("" if not c.get("rerank") else " · rerank")
lines.append(f"docs top_k {c.get('top_k', 5)}{flags}")
if c.get("include_qa"):
lines.append(f"Q&A top_k {c.get('qa_top_k', 3)}")
return lines or ["no sources enabled"]
register(NodeSpec(
type="retrieval", schema_id="forge/nodes/retrieval",
input_ports=[Port(id="in", io_type="text", direction="in")],
output_ports=[Port(id="out", io_type="json", direction="out")],
factory=retrieval_factory, category="knowledge", label="Retrieval",
description="RAG document search + Q&A lookup",
summarize=_retrieval_summary,
))
+53
View File
@@ -0,0 +1,53 @@
"""Trigger nodes - event-driven entry points (Phase 3).
A trigger node is the workflow's entry: the dispatcher (webhook route / scheduler /
inbound email) creates a run whose `input` already carries the inbound
message, then the graph runs from the trigger node onward. At compile time a trigger is
a passthrough, exactly like `start`; its config drives the DISPATCHER (which path /
schedule / mailbox), not the graph body.
Registering them as node types means they appear in the palette, validate via schema,
and the validator's "reachable from entry / path to END" rules apply unchanged.
"""
from __future__ import annotations
from forge.engine.context import CompileContext
from forge.engine.registry import NodeSpec, Port, register
TRIGGER_TYPES = ("webhook_in", "schedule", "email_in", "app_event")
def _passthrough_factory(config: dict, ctx: CompileContext):
def _node(state: dict) -> dict:
return {}
return _node
_out = [Port(id="out", io_type="control", direction="out")]
register(NodeSpec(
type="webhook_in", schema_id="forge/nodes/trigger_webhook",
input_ports=[], output_ports=_out, factory=_passthrough_factory,
category="triggers", label="Webhook", description="Run when an external system POSTs to this workflow's hook URL.",
summarize=lambda c: ["inbound webhook", "signed" if c.get("require_signature") else "unsigned"],
))
register(NodeSpec(
type="schedule", schema_id="forge/nodes/trigger_schedule",
input_ports=[], output_ports=_out, factory=_passthrough_factory,
category="triggers", label="Schedule", description="Run on a recurring schedule (cron or every N minutes).",
summarize=lambda c: [c.get("cron") or (f"every {c.get('every_minutes', '-')} min")],
))
register(NodeSpec(
type="email_in", schema_id="forge/nodes/trigger_email",
input_ports=[], output_ports=_out, factory=_passthrough_factory,
category="triggers", label="Email", description="Run when an email arrives in the connected mailbox; optionally reply.",
summarize=lambda c: [c.get("mailbox") or "inbound email", "reply" if c.get("reply", True) else "no reply"],
))
register(NodeSpec(
type="app_event", schema_id="forge/nodes/trigger_app_event",
input_ports=[], output_ports=_out, factory=_passthrough_factory,
category="triggers", label="App Event", description="Poll an external source; run once per new item.",
summarize=lambda c: [str(c.get("poll_url", ""))[:32], f"every {c.get('interval_minutes', 5)} min"],
))
+65
View File
@@ -0,0 +1,65 @@
"""Optional arq/Redis job queue for OFFLOADED run execution (audit P1).
The interactive SSE path stays inline (it must stream tokens to the caller). The
*non-interactive* paths - webhook + schedule triggers - don't need a synchronous reply,
so when a Redis/arq worker is configured they're enqueued instead of run on the web
process, which keeps long LLM runs off the request thread and gives backpressure +
retries via the worker tier. With no Redis configured, `enqueue_run` returns False and
the caller runs inline (the always-available default).
Safe to import anywhere: arq is imported lazily inside the functions, so the API process
doesn't need the `workers` extra installed.
"""
from __future__ import annotations
import contextlib
import logging
from forge.config import settings
log = logging.getLogger("forge.queue")
_pool = None
def queue_enabled() -> bool:
if not settings.redis_url:
return False
try:
import arq # noqa: F401
except Exception: # noqa: BLE001 - arq (workers extra) not installed
return False
return True
async def _get_pool():
global _pool
if _pool is None:
from arq import create_pool
from arq.connections import RedisSettings
_pool = await create_pool(RedisSettings.from_dsn(settings.redis_url))
return _pool
async def enqueue_run(run_id: str, tenant_id: str, project_id: str | None = None) -> bool:
"""Enqueue a run for the worker. Returns False (caller runs inline) when no queue is
configured or enqueue fails - so a Redis blip degrades to inline, never drops the run."""
if not queue_enabled():
return False
try:
pool = await _get_pool()
await pool.enqueue_job("run_job", run_id, tenant_id, project_id)
return True
except Exception: # noqa: BLE001
log.exception("failed to enqueue run %s; falling back to inline execution", run_id)
return False
async def close_pool() -> None:
global _pool
if _pool is not None:
with contextlib.suppress(Exception):
await _pool.aclose()
_pool = None
+1
View File
@@ -0,0 +1 @@
"""HTTP + SSE routers."""
+89
View File
@@ -0,0 +1,89 @@
"""Agent preset endpoints (CRUD + validate)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.dto import (
AgentCreate,
AgentOut,
AgentUpdate,
ExportIn,
ImportIn,
ImportReport,
ValidateOut,
)
from forge.services.agents import AgentService
from forge.services.portability import PortabilityService
from forge.services.versions import safe_snapshot
router = APIRouter(prefix="/v1/projects/{project_id}/agents", tags=["agents"])
@router.post("/export")
async def export_agents(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Serialize the selected agent presets (full config) into a downloadable bundle."""
return await PortabilityService.export(session, tenant_id, project_id, "agent", body.ids)
@router.post("/import", response_model=ImportReport)
async def import_agents(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
"""Create agent presets from an uploaded bundle in THIS project (auto-renamed on collision)."""
if body.type not in (None, "agent"):
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
try:
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
except ValueError as e:
raise HTTPException(422, str(e)) from e
@router.get("", response_model=list[AgentOut])
async def list_agents(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await AgentService.list(session, tenant_id, project_id)
@router.post("", response_model=AgentOut, status_code=201)
async def create_agent(project_id: str, body: AgentCreate, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
agent = await AgentService.create(session, tenant_id, project_id, name=body.name, config=body.config,
created_by=user.id, created_by_email=user.email)
await safe_snapshot(session, "agent", agent, author=user)
return agent
@router.get("/{agent_id}", response_model=AgentOut)
async def get_agent(project_id: str, agent_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
agent = await AgentService.get(session, tenant_id, agent_id)
if agent is None:
raise HTTPException(404, "Agent not found")
return agent
@router.patch("/{agent_id}", response_model=AgentOut)
async def update_agent(project_id: str, agent_id: str, body: AgentUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
agent = await AgentService.get(session, tenant_id, agent_id)
if agent is None:
raise HTTPException(404, "Agent not found")
agent = await AgentService.update(session, agent, name=body.name, config=body.config)
await safe_snapshot(session, "agent", agent, author=user)
return agent
@router.delete("/{agent_id}", status_code=204)
async def delete_agent(project_id: str, agent_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
agent = await AgentService.get(session, tenant_id, agent_id)
if agent is None:
raise HTTPException(404, "Agent not found")
await AgentService.delete(session, agent)
@router.post("/validate", response_model=ValidateOut)
async def validate_agent(project_id: str, body: AgentCreate):
errors = AgentService.validate(body.config)
return ValidateOut(valid=not errors, errors=errors)
+90
View File
@@ -0,0 +1,90 @@
"""Forge Assistant endpoint - streams the meta-agent's narration + actions over SSE."""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from forge.deps import current_tenant_id, get_checkpointer, get_session
from forge.services.assistant import AssistantService
from forge.services.projects import ProjectService
router = APIRouter(prefix="/v1/projects/{project_id}/assistant", tags=["assistant"])
SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
}
class AssistantMessage(BaseModel):
role: str = "user"
content: str
class AssistantIn(BaseModel):
# Preferred: one new message + a stable thread_id (the checkpointer holds history,
# todos, and files for the thread). `messages` remains for back-compat - when
# thread_id is absent the full transcript is replayed statelessly.
message: str | None = None
thread_id: str | None = None
messages: list[AssistantMessage] = []
class AssistantResumeIn(BaseModel):
thread_id: str
decision: str = "approve" # approve | reject
@router.post("/stream")
async def assistant_stream(
project_id: str,
body: AssistantIn,
tenant_id: str = Depends(current_tenant_id),
checkpointer=Depends(get_checkpointer),
session: AsyncSession = Depends(get_session),
):
# Ownership check (audit H4): the assistant runs tools scoped to the URL's project_id; without
# this, a caller in tenant A could target tenant B's project and read/write its resources. 404
# (not 403) so a foreign project id is indistinguishable from a missing one.
if await ProjectService.get(session, tenant_id, project_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
if body.message:
messages = [{"role": "user", "content": body.message}]
else:
messages = [m.model_dump() for m in body.messages]
async def event_gen():
async for frame in AssistantService.stream(
tenant_id=tenant_id, project_id=project_id, messages=messages,
thread_id=body.thread_id, checkpointer=checkpointer,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
@router.post("/resume")
async def assistant_resume(
project_id: str,
body: AssistantResumeIn,
tenant_id: str = Depends(current_tenant_id),
checkpointer=Depends(get_checkpointer),
session: AsyncSession = Depends(get_session),
):
"""Resume a paused assistant thread (HITL approval for destructive tools)."""
if await ProjectService.get(session, tenant_id, project_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
async def event_gen():
async for frame in AssistantService.resume(
tenant_id=tenant_id, project_id=project_id, thread_id=body.thread_id,
decision=body.decision, checkpointer=checkpointer,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
+89
View File
@@ -0,0 +1,89 @@
"""Audit-log read + export endpoints (admin+).
The audit trail is APPEND-ONLY: these endpoints only ever read it (there is deliberately no
update/delete route). See services/audit.py and infra/postgres_rls.sql for the invariant.
"""
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.services.audit import AuditService
router = APIRouter(prefix="/v1/audit", tags=["audit"])
@router.get("/metrics")
async def metrics(_: CurrentUser = Depends(require_role("admin"))):
"""In-process resilience counters (swallowed-failure visibility)."""
from forge.util.metrics import snapshot
return snapshot()
def _row(r) -> dict:
return {
"id": r.id, "action": r.action, "actor_email": r.actor_email, "actor_id": r.actor_id,
"resource_type": r.resource_type, "resource_id": r.resource_id, "project_id": r.project_id,
"ip": r.ip, "status": r.status, "meta": r.meta, "at": r.created_at.isoformat() if r.created_at else None,
}
@router.get("")
async def list_audit(
response: Response,
project_id: str | None = None,
action: str | None = None,
actor: str | None = Query(None, description="match actor_email or actor_id"),
start: datetime | None = Query(None, description="only entries at/after this time (ISO 8601)"),
end: datetime | None = Query(None, description="only entries at/before this time (ISO 8601)"),
cursor: str | None = Query(None, description="opaque keyset cursor from a prior page"),
limit: int = 200,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin")),
):
"""Filtered, keyset-paginated audit list (newest first). Returns a JSON array (unchanged
shape); the opaque cursor for the next page, when there is one, is in the `X-Next-Cursor`
response header (finding g)."""
try:
rows, next_cursor = await AuditService.query(
session, tenant_id, action=action, actor=actor, project_id=project_id,
start=start, end=end, cursor=cursor, limit=min(limit, 1000),
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
if next_cursor:
response.headers["X-Next-Cursor"] = next_cursor
return [_row(r) for r in rows]
@router.get("/export")
async def export_audit(
project_id: str | None = None,
action: str | None = None,
actor: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin")),
):
"""Stream ALL matching audit rows as newline-delimited JSON (oldest first), paged internally
so a large export never buffers the whole table (finding g)."""
async def _gen():
async for r in AuditService.export(session, tenant_id, action=action, actor=actor,
project_id=project_id, start=start, end=end):
yield json.dumps(_row(r), default=str) + "\n"
return StreamingResponse(
_gen(), media_type="application/x-ndjson",
headers={"Content-Disposition": "attachment; filename=forge-audit.ndjson"},
)
+593
View File
@@ -0,0 +1,593 @@
"""Auth + team-management endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from forge.config import settings
from forge.deps import (
CurrentUser,
client_ip,
current_tenant_id,
get_current_user,
get_session,
require_role,
)
from forge.security import (
TokenError,
create_email_verification_token,
create_invite_token,
create_password_reset_token,
decode_token,
is_revoked,
revoke,
revoke_user_tokens,
tokens_revoked_after,
totp_provisioning_uri,
)
from forge.services.audit import AuditService
from forge.services.auth import AuthError, AuthService
from forge.util.mailer import send_email
from forge.util.ratelimit import rate_limiter
router = APIRouter(prefix="/v1/auth", tags=["auth"])
team_router = APIRouter(prefix="/v1/team", tags=["team"])
workspace_router = APIRouter(prefix="/v1/workspace", tags=["workspace"])
apikeys_router = APIRouter(prefix="/v1/api-keys", tags=["api-keys"])
def _auth_throttle(request: Request, email: str | None = None) -> None:
"""Ceiling on the unauthenticated auth endpoints - brute-force / credential-stuffing guard
(finding a). Two dimensions: a STRICT per-email bucket (per-account brute-force) and a LOOSER
per-IP bucket at 10x (credential-stuffing / DoS across many accounts). 429 when either is
empty. `auth_rate_limit_per_minute` is the per-email rate; 0 disables both."""
rate = settings.auth_rate_limit_per_minute
if rate <= 0:
return
ip = client_ip(request) or "unknown"
checks = [(f"auth:ip:{ip}", rate * 10)]
if email:
checks.append((f"auth:email:{email.strip().lower()}", rate))
for key, per_min in checks:
if not rate_limiter.allow(key, rate=per_min, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts; please slow down and try again")
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
workspace_name: str | None = None
class LoginIn(BaseModel):
# plain str (not EmailStr): login accepts whatever was registered, incl. local addresses.
email: str
password: str
workspace_id: str | None = None
totp_code: str | None = None # required only when the account has TOTP MFA enabled
class RefreshIn(BaseModel):
refresh_token: str
class LogoutIn(BaseModel):
# Optional: the refresh token to revoke. Unauthenticated (possession of the signed token is
# itself the proof) so it works even after the access token has expired.
refresh_token: str | None = None
class InviteIn(BaseModel):
email: EmailStr
role: str = "editor"
password: str | None = Field(default=None, min_length=8)
class AcceptInviteIn(BaseModel):
token: str
password: str = Field(min_length=8)
def _invite_link(token: str) -> str:
return f"{settings.public_console_url.rstrip('/')}/?invite={token}"
async def _send_invite_email(*, to: str, link: str, inviter: str | None, role: str) -> bool:
who = f"{inviter} " if inviter else ""
subject = "You've been invited to Forge"
body = (
f"{who}invited you to join their Forge workspace as a {role}.\n\n"
f"Set your password and get started:\n{link}\n\n"
"This link expires in 7 days. If you weren't expecting this, you can ignore this email."
)
html = (
f"<p>{who}invited you to join their Forge workspace as a <b>{role}</b>.</p>"
f'<p><a href="{link}">Set your password and get started →</a></p>'
"<p style='color:#888;font-size:13px'>This link expires in 7 days. "
"If you weren't expecting this, you can ignore this email.</p>"
)
return await send_email(to=to, subject=subject, body=body, html=html)
class MemberPatch(BaseModel):
role: str | None = None
status: str | None = None
class PasswordIn(BaseModel):
password: str = Field(min_length=8)
def _user_out(u) -> dict:
return {"id": u.id, "email": u.email, "role": u.role, "status": u.status, "tenant_id": u.tenant_id}
@router.post("/register", status_code=201)
async def register(body: RegisterIn, request: Request, session: AsyncSession = Depends(get_session)):
if not settings.allow_open_signup:
raise HTTPException(status.HTTP_403_FORBIDDEN, "open signup is disabled; ask an admin for an invite")
_auth_throttle(request, str(body.email))
try:
user = await AuthService.register(
session, email=str(body.email), password=body.password, workspace_name=body.workspace_name
)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=user.tenant_id, action="auth.register", actor_id=user.id,
actor_email=user.email, ip=client_ip(request))
return {**AuthService.tokens_for(user), "user": _user_out(user)}
@router.post("/login")
async def login(body: LoginIn, request: Request, session: AsyncSession = Depends(get_session)):
_auth_throttle(request, str(body.email))
try:
user = await AuthService.authenticate(
session, email=str(body.email), password=body.password, tenant_id=body.workspace_id,
)
except AuthError as e:
await AuditService.log(tenant_id="-", action="auth.login", actor_email=str(body.email),
ip=client_ip(request), status="denied", meta={"reason": str(e)})
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
# Second factor (only enforced when the account has TOTP enabled).
if not await AuthService.check_login_totp(session, user, body.totp_code):
await AuditService.log(tenant_id=user.tenant_id, action="auth.login", actor_id=user.id,
actor_email=user.email, ip=client_ip(request), status="denied",
meta={"reason": "totp_required_or_invalid"})
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "a valid authenticator code is required", {"WWW-Authenticate": "TOTP"})
await AuditService.log(tenant_id=user.tenant_id, action="auth.login", actor_id=user.id,
actor_email=user.email, ip=client_ip(request))
return {**AuthService.tokens_for(user), "user": _user_out(user)}
@router.post("/refresh")
async def refresh(body: RefreshIn, request: Request, session: AsyncSession = Depends(get_session)):
_auth_throttle(request)
# Decode WITHOUT the revoked-check so a reused (already-rotated) refresh token is detected
# rather than looking like a plain invalid token (finding d).
try:
claims = decode_token(body.refresh_token, expected_type="refresh", check_revoked=False)
except TokenError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
uid = claims.get("sub", "")
# Reuse detection: a rotated refresh token presented again is a theft signal -> sign the
# whole user out (revoke every session) and refuse.
if is_revoked(claims.get("jti")):
revoke_user_tokens(uid)
await AuditService.log(tenant_id=claims.get("tid", "-"), action="auth.refresh_reuse",
actor_id=uid, ip=client_ip(request), status="denied")
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "refresh token reuse detected; all sessions signed out")
if tokens_revoked_after(claims):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session has been signed out")
user = await AuthService.get_user(session, uid)
if user is None or user.status != "active":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account not found or disabled")
# Rotate: denylist the presented refresh jti (self-expiring at its own exp) and mint a fresh
# access + refresh pair.
revoke(claims.get("jti"), exp=claims.get("exp"))
return AuthService.tokens_for(user)
@router.post("/logout")
async def logout(body: LogoutIn):
"""Revoke the presented refresh token (this device/session). Unauthenticated: possession of
the signed token is the proof, so it works even after the access token expires (finding d)."""
if body.refresh_token:
try:
claims = decode_token(body.refresh_token, expected_type="refresh", check_revoked=False)
revoke(claims.get("jti"), exp=claims.get("exp"))
except TokenError:
pass # already invalid/expired - nothing to revoke
return {"ok": True}
@router.post("/logout-all")
async def logout_all(request: Request, user: CurrentUser = Depends(get_current_user)):
"""Sign out every session for the current user (all devices) by advancing their revocation
cutoff so all previously-issued access/refresh tokens are rejected (finding d)."""
revoke_user_tokens(user.id)
await AuditService.log(tenant_id=user.tenant_id, action="auth.logout_all", actor_id=user.id,
actor_email=user.email, ip=client_ip(request))
return {"ok": True}
@router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)):
return {"id": user.id, "email": user.email, "role": user.role, "tenant_id": user.tenant_id,
"is_fallback": user.is_fallback}
@router.post("/set-password")
async def set_my_password(body: PasswordIn, user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
if user.is_fallback:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot set a password for the fallback dev user")
try:
await AuthService.set_password(session, user_id=user.id, password=body.password)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
return {"ok": True}
# --- team management (admin+) ---
@team_router.get("/members")
async def list_members(session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin"))):
return [_user_out(u) for u in await AuthService.list_members(session, tenant_id)]
@team_router.post("/members", status_code=201)
async def invite_member(body: InviteIn, request: Request, session: AsyncSession = Depends(get_session),
admin: CurrentUser = Depends(require_role("admin"))):
try:
user = await AuthService.invite(session, tenant_id=admin.tenant_id, email=str(body.email),
role=body.role, password=body.password)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=admin.tenant_id, action="team.invite", actor_id=admin.id,
actor_email=admin.email, resource_type="user", resource_id=user.id,
ip=client_ip(request), meta={"email": user.email, "role": user.role})
out = _user_out(user)
# No password set => emailed-invite flow: mint a redeemable link and try to send it.
# If SMTP isn't configured, hand the link back so the admin can share it manually.
if user.status == "invited":
token = create_invite_token(user_id=user.id, tenant_id=user.tenant_id)
link = _invite_link(token)
out["email_sent"] = await _send_invite_email(to=user.email, link=link, inviter=admin.email, role=user.role)
if not out["email_sent"]:
out["invite_url"] = link
else:
out["email_sent"] = False # admin set a temp password; share it out-of-band
return out
@router.get("/invite-info")
async def invite_info(token: str, session: AsyncSession = Depends(get_session)):
"""Public: validate an invite token and return who it's for (for the accept screen)."""
try:
claims = decode_token(token, expected_type="invite")
except TokenError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite link is invalid or has expired") from e
user = await AuthService.get_user(session, claims.get("sub", ""))
if user is None or user.status != "invited":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite has already been used or was revoked")
return {"email": user.email, "role": user.role}
@router.post("/accept-invite")
async def accept_invite(body: AcceptInviteIn, request: Request, session: AsyncSession = Depends(get_session)):
"""Public: redeem an invite token, set the first password, and log the user in."""
_auth_throttle(request)
try:
claims = decode_token(body.token, expected_type="invite")
except TokenError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite link is invalid or has expired") from e
try:
user = await AuthService.accept_invite(
session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""), password=body.password
)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=user.tenant_id, action="auth.accept_invite", actor_id=user.id,
actor_email=user.email, ip=client_ip(request))
return {**AuthService.tokens_for(user), "user": _user_out(user)}
@team_router.patch("/members/{user_id}")
async def update_member(user_id: str, body: MemberPatch, request: Request,
session: AsyncSession = Depends(get_session),
admin: CurrentUser = Depends(require_role("admin"))):
try:
user = None
if body.role is not None:
user = await AuthService.set_role(session, tenant_id=admin.tenant_id, user_id=user_id, role=body.role)
if body.status is not None:
user = await AuthService.set_status(session, tenant_id=admin.tenant_id, user_id=user_id, status=body.status)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
if user is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "nothing to update")
await AuditService.log(tenant_id=admin.tenant_id, action="team.update", actor_id=admin.id,
actor_email=admin.email, resource_type="user", resource_id=user_id,
ip=client_ip(request), meta=body.model_dump(exclude_none=True))
return _user_out(user)
@team_router.delete("/members/{user_id}")
async def deactivate_member(user_id: str, request: Request, session: AsyncSession = Depends(get_session),
admin: CurrentUser = Depends(require_role("admin"))):
if user_id == admin.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "you cannot deactivate yourself")
try:
await AuthService.set_status(session, tenant_id=admin.tenant_id, user_id=user_id, status="disabled")
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=admin.tenant_id, action="team.deactivate", actor_id=admin.id,
actor_email=admin.email, resource_type="user", resource_id=user_id, ip=client_ip(request))
return {"ok": True}
# --- password reset (finding j) ---
class PasswordResetRequestIn(BaseModel):
email: EmailStr
workspace_id: str | None = None
class PasswordResetIn(BaseModel):
token: str
password: str = Field(min_length=8)
async def _send_link_email(*, to: str, subject: str, intro: str, link: str, expires: str) -> bool:
body = f"{intro}\n\n{link}\n\nThis link expires in {expires}. If you weren't expecting this, ignore this email."
html = (f"<p>{intro}</p><p><a href=\"{link}\">Continue →</a></p>"
f"<p style='color:#888;font-size:13px'>This link expires in {expires}. "
"If you weren't expecting this, you can ignore this email.</p>")
return await send_email(to=to, subject=subject, body=body, html=html)
@router.post("/request-password-reset")
async def request_password_reset(body: PasswordResetRequestIn, request: Request,
session: AsyncSession = Depends(get_session)):
"""Public: email a signed reset link. Always returns ok (never reveals whether the address
exists). No SMTP configured => the link is returned so an admin/dev can use it."""
_auth_throttle(request, str(body.email))
user = await AuthService.get_by_email(
session, str(body.email), tenant_id=body.workspace_id,
)
if user and user.status != "disabled":
token = create_password_reset_token(user_id=user.id, tenant_id=user.tenant_id)
link = f"{settings.public_console_url.rstrip('/')}/?reset={token}"
sent = await _send_link_email(to=user.email, subject="Reset your Forge password",
intro="Use the link below to set a new password.",
link=link, expires="1 hour")
await AuditService.log(tenant_id=user.tenant_id, action="auth.request_password_reset",
actor_id=user.id, actor_email=user.email, ip=client_ip(request))
if not sent:
return {"ok": True, "reset_url": link}
return {"ok": True}
@router.post("/reset-password")
async def reset_password(body: PasswordResetIn, request: Request,
session: AsyncSession = Depends(get_session)):
"""Public: redeem a reset token, set a new password, and sign out all existing sessions.
Does NOT auto-login (user re-authenticates, re-prompting MFA)."""
_auth_throttle(request)
try:
claims = decode_token(body.token, expected_type="pwreset")
except TokenError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this reset link is invalid or has expired") from e
try:
user = await AuthService.reset_password(
session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""), password=body.password
)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
revoke(claims.get("jti"), exp=claims.get("exp")) # one-time use
await AuditService.log(tenant_id=user.tenant_id, action="auth.reset_password", actor_id=user.id,
actor_email=user.email, ip=client_ip(request))
return {"ok": True}
# --- email verification (finding j) ---
class TokenIn(BaseModel):
token: str
@router.post("/request-email-verification")
async def request_email_verification(request: Request, user: CurrentUser = Depends(get_current_user)):
if user.is_fallback or not user.email:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "no verifiable email for this account")
token = create_email_verification_token(user_id=user.id, tenant_id=user.tenant_id)
link = f"{settings.public_console_url.rstrip('/')}/?verify_email={token}"
sent = await _send_link_email(to=user.email, subject="Verify your Forge email",
intro="Confirm this email address for your Forge account.",
link=link, expires="3 days")
return {"ok": True} if sent else {"ok": True, "verify_url": link}
@router.post("/verify-email")
async def verify_email(body: TokenIn, session: AsyncSession = Depends(get_session)):
try:
claims = decode_token(body.token, expected_type="email_verify")
except TokenError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this verification link is invalid or has expired") from e
try:
await AuthService.mark_email_verified(session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""))
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
revoke(claims.get("jti"), exp=claims.get("exp"))
return {"ok": True}
# --- TOTP MFA (finding j; optional per user) ---
class TotpCodeIn(BaseModel):
code: str
@router.post("/mfa/totp/enroll")
async def totp_enroll(user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session)):
"""Generate a TOTP secret (NOT yet active - confirm a code to enable). Returns the secret
and an otpauth:// URL for an authenticator-app QR."""
if user.is_fallback:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot enroll MFA for the dev fallback user")
u = await AuthService.get_user(session, user.id)
if u is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
secret = await AuthService.enroll_totp(session, u)
return {"secret": secret, "otpauth_url": totp_provisioning_uri(secret, account=u.email, issuer=settings.app_name)}
@router.post("/mfa/totp/confirm")
async def totp_confirm(body: TotpCodeIn, user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
u = await AuthService.get_user(session, user.id)
if u is None or not await AuthService.confirm_totp(session, user=u, code=body.code):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid authenticator code")
return {"ok": True, "mfa_enabled": True}
@router.post("/mfa/totp/disable")
async def totp_disable(body: TotpCodeIn, user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session)):
"""Disable MFA. Requires a current code so a hijacked session can't silently turn it off."""
u = await AuthService.get_user(session, user.id)
if u is None or not await AuthService.check_login_totp(session, u, body.code):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid authenticator code")
await AuthService.disable_totp(session, u)
return {"ok": True, "mfa_enabled": False}
# --- workspace (tenant) administration (finding k) ---
class WorkspaceUpdateIn(BaseModel):
name: str | None = None
plan: str | None = None
# Quota / limit overrides merged into tenant.settings (max_runs_per_day, max_cost_per_day_usd,
# max_tokens_per_day, project_limits, reset_tz, ...). Merged, not replaced.
settings: dict | None = None
class WorkspaceDeleteIn(BaseModel):
# Must equal the workspace name - a deliberate, un-fat-fingerable confirmation for a
# destructive, irreversible cascade.
confirm_name: str
def _tenant_out(t) -> dict:
return {"id": t.id, "name": t.name, "plan": t.plan, "settings": t.settings or {}}
@workspace_router.get("")
async def get_workspace(session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin"))):
from forge.models import Tenant
t = await session.get(Tenant, tenant_id)
if t is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace not found")
return _tenant_out(t)
@workspace_router.patch("")
async def update_workspace(body: WorkspaceUpdateIn, request: Request,
session: AsyncSession = Depends(get_session),
owner: CurrentUser = Depends(require_role("owner"))):
"""Owner-only: rename, change plan, or adjust quota limits (finding k)."""
try:
t = await AuthService.update_workspace(
session, tenant_id=owner.tenant_id, name=body.name, plan=body.plan, settings_patch=body.settings
)
except AuthError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=owner.tenant_id, action="workspace.update", actor_id=owner.id,
actor_email=owner.email, ip=client_ip(request),
meta=body.model_dump(exclude_none=True))
return _tenant_out(t)
@workspace_router.delete("", status_code=204)
async def delete_workspace(body: WorkspaceDeleteIn, request: Request,
session: AsyncSession = Depends(get_session),
owner: CurrentUser = Depends(require_role("owner"))):
"""Owner-only, name-confirmed, cascading workspace deletion (finding k). Irreversible."""
from forge.models import Tenant
t = await session.get(Tenant, owner.tenant_id)
if t is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace not found")
if body.confirm_name != t.name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "confirm_name does not match the workspace name")
await AuditService.log(tenant_id=owner.tenant_id, action="workspace.delete", actor_id=owner.id,
actor_email=owner.email, ip=client_ip(request), meta={"name": t.name})
await AuthService.delete_workspace(
session, tenant_id=owner.tenant_id,
checkpointer=getattr(request.app.state, "checkpointer", None),
)
# --- API keys (finding h) ---
class ApiKeyCreateIn(BaseModel):
name: str
role: str = "editor"
ttl_days: int | None = None
def _apikey_out(k, *, plaintext: str | None = None) -> dict:
out = {"id": k.id, "name": k.name, "role": k.role, "status": k.status, "prefix": k.prefix,
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
"expires_at": k.expires_at.isoformat() if k.expires_at else None}
if plaintext is not None:
out["key"] = plaintext # shown ONCE at creation
return out
@apikeys_router.get("")
async def list_api_keys(session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin"))):
from forge.services.apikeys import ApiKeyService
return [_apikey_out(k) for k in await ApiKeyService.list(session, tenant_id)]
@apikeys_router.post("", status_code=201)
async def create_api_key(body: ApiKeyCreateIn, request: Request,
session: AsyncSession = Depends(get_session),
admin: CurrentUser = Depends(require_role("admin"))):
from forge.services.apikeys import ApiKeyService
# An admin must not be able to mint a key MORE privileged than themselves.
from forge.services.auth import role_at_least
if not role_at_least(admin.role, body.role):
raise HTTPException(status.HTTP_403_FORBIDDEN, "cannot create a key more privileged than your role")
try:
key, plaintext = await ApiKeyService.create(
session, tenant_id=admin.tenant_id, name=body.name, role=body.role,
created_by=admin.id, ttl_days=body.ttl_days,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
await AuditService.log(tenant_id=admin.tenant_id, action="apikey.create", actor_id=admin.id,
actor_email=admin.email, resource_type="api_key", resource_id=key.id,
ip=client_ip(request), meta={"name": key.name, "role": key.role})
return _apikey_out(key, plaintext=plaintext)
@apikeys_router.delete("/{key_id}", status_code=204)
async def revoke_api_key(key_id: str, request: Request,
session: AsyncSession = Depends(get_session),
admin: CurrentUser = Depends(require_role("admin"))):
from forge.services.apikeys import ApiKeyService
if not await ApiKeyService.revoke(session, tenant_id=admin.tenant_id, key_id=key_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "API key not found")
await AuditService.log(tenant_id=admin.tenant_id, action="apikey.revoke", actor_id=admin.id,
actor_email=admin.email, resource_type="api_key", resource_id=key_id,
ip=client_ip(request))
+122
View File
@@ -0,0 +1,122 @@
"""Auth Provider endpoints (CRUD + /test)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.contracts import validate_against_id
from forge.schemas.dto import (
AuthProviderCreate,
AuthProviderOut,
AuthProviderUpdate,
AuthTestIn,
UserConnectionIn,
)
from forge.services.auth_providers import AuthProviderService
from forge.services.versions import safe_snapshot
router = APIRouter(prefix="/v1/projects/{project_id}/auth-providers", tags=["auth-providers"])
@router.get("", response_model=list[AuthProviderOut])
async def list_aps(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await AuthProviderService.list(session, tenant_id, project_id)
@router.post("", response_model=AuthProviderOut, status_code=201)
async def create_ap(project_id: str, body: AuthProviderCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
cfg = {**body.config, "name": body.name, "kind": body.kind}
if body.credentials_ref:
cfg["credentials_ref"] = body.credentials_ref
errors = validate_against_id(cfg, "forge/auth_provider")
if errors:
raise HTTPException(422, detail={"errors": errors})
ap = await AuthProviderService.create(session, tenant_id, project_id, name=body.name, kind=body.kind, config=body.config, credentials_ref=body.credentials_ref)
await safe_snapshot(session, "auth_provider", ap, author=user)
return ap
@router.get("/{ap_id}", response_model=AuthProviderOut)
async def get_ap(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
return ap
@router.patch("/{ap_id}", response_model=AuthProviderOut)
async def update_ap(project_id: str, ap_id: str, body: AuthProviderUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
if body.config is not None:
cfg = {**body.config, "name": body.name or ap.name, "kind": body.kind or ap.kind}
if body.credentials_ref:
cfg["credentials_ref"] = body.credentials_ref
errors = validate_against_id(cfg, "forge/auth_provider")
if errors:
raise HTTPException(422, detail={"errors": errors})
ap = await AuthProviderService.update(session, ap, name=body.name, kind=body.kind, config=body.config, credentials_ref=body.credentials_ref)
await safe_snapshot(session, "auth_provider", ap, author=user)
return ap
@router.delete("/{ap_id}", status_code=204)
async def delete_ap(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
await AuthProviderService.delete(session, ap)
@router.post("/{ap_id}/test")
async def test_ap(project_id: str, ap_id: str, body: AuthTestIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
# Test AS the current user so a per-user provider resolves the tester's own connected token
# (end_user_id keys the per-user bundle). Explicit context values still win.
ctx = {"end_user_id": user.id, **(body.context or {})}
return await AuthProviderService.test(tenant_id, project_id, ap, ctx)
# --- Per-user connected credentials: the app owner's connect flow stores each end user's downstream
# credential here (server-to-server, editor+), and the AuthResolver uses it to act as that user. ---
@router.put("/{ap_id}/connections/{end_user_id}", status_code=204)
async def set_user_connection(project_id: str, ap_id: str, end_user_id: str, body: UserConnectionIn,
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
bundle = {"access_token": body.access_token, **(body.extra or {})}
if body.refresh_token:
bundle["refresh_token"] = body.refresh_token
if body.expires_at is not None:
bundle["expires_at"] = body.expires_at
await AuthProviderService.set_user_connection(session, tenant_id, project_id, ap, end_user_id, bundle=bundle)
@router.get("/{ap_id}/connections/{end_user_id}")
async def get_user_connection(project_id: str, ap_id: str, end_user_id: str,
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
return await AuthProviderService.get_user_connection(tenant_id, project_id, ap, end_user_id)
@router.delete("/{ap_id}/connections/{end_user_id}", status_code=204)
async def delete_user_connection(project_id: str, ap_id: str, end_user_id: str,
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(404, "Auth provider not found")
await AuthProviderService.clear_user_connection(session, tenant_id, project_id, ap, end_user_id)
+169
View File
@@ -0,0 +1,169 @@
"""Channel CRUD + the public email inbound endpoint."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from forge.channels import email as email_ch
from forge.config import settings
from forge.db.base import SessionLocal
from forge.deps import CurrentUser, current_tenant_id, get_run_service, get_session, require_role
from forge.services.channels import ChannelService
from forge.services.dispatch import dispatch_message
from forge.services.handoff import (
HITL_META_KEY,
HandoffService,
interrupt_ack,
interrupt_hitl_meta,
interrupt_reason,
)
from forge.services.runs import RunService
from forge.util.ratelimit import rate_limiter
log = logging.getLogger("forge.channels.router")
router = APIRouter(prefix="/v1/projects/{project_id}/channels", tags=["channels"])
public = APIRouter(tags=["channels"]) # unauthenticated inbound webhooks
class ChannelIn(BaseModel):
type: str
name: str
workflow_id: str | None = None
config: dict = {}
class ChannelPatch(BaseModel):
name: str | None = None
workflow_id: str | None = None
config: dict | None = None
enabled: bool | None = None
def _out(ch) -> dict:
base = settings.public_base_url.rstrip("/")
item = {"id": ch.id, "type": ch.type, "name": ch.name, "workflow_id": ch.workflow_id,
"enabled": ch.enabled, "config": ch.config, "key": ch.key}
if ch.type == "email":
item["inbound_url"] = f"{base}/v1/channels/email/{ch.key}/inbound"
return item
@router.get("")
async def list_channels(project_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
return [_out(c) for c in await ChannelService.list(session, tenant_id, project_id)]
@router.post("", status_code=201)
async def create_channel(project_id: str, body: ChannelIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
try:
ch = await ChannelService.create(session, tenant_id, project_id, type_=body.type, name=body.name,
workflow_id=body.workflow_id, config=body.config)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
return _out(ch)
@router.patch("/{channel_id}")
async def update_channel(project_id: str, channel_id: str, body: ChannelPatch,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ch = await ChannelService.get(session, tenant_id, channel_id)
if not ch:
raise HTTPException(status.HTTP_404_NOT_FOUND, "channel not found")
ch = await ChannelService.update(session, ch, name=body.name, workflow_id=body.workflow_id,
config=body.config, enabled=body.enabled)
return _out(ch)
@router.delete("/{channel_id}")
async def delete_channel(project_id: str, channel_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ch = await ChannelService.get(session, tenant_id, channel_id)
if not ch:
raise HTTPException(status.HTTP_404_NOT_FOUND, "channel not found")
await ChannelService.delete(session, ch)
return {"ok": True}
# --------------------- public inbound ---------------------
async def _resolve(type_: str, key: str):
async with SessionLocal() as s:
ch = await ChannelService.by_key(s, type_, key)
if ch is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown or disabled channel")
workflow_id = await ChannelService.resolve_workflow_id(s, ch)
if not workflow_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "channel has no workflow bound")
return ch, workflow_id
async def _maybe_open_handoff(ch, result: dict, *, customer, customer_message, reply_context) -> str | None:
"""If the run paused at an interrupt, open a HandoffRequest and return a customer-facing
acknowledgement. Email can't resume an HITL pause inline, so ANY
interrupt - explicit handoff OR an approval/input pause - must be tracked and acknowledged
rather than falling through to a stale/empty partial answer (audit F8)."""
if not result.get("interrupted"):
return None
interrupts = result.get("interrupts")
reason = interrupt_reason(interrupts) or (
"Conversation paused awaiting input/approval - a team member will follow up."
)
# Persist the interrupting node's allowed_decisions so the human's channel reply is coerced
# to a valid decision before resuming (a Router keyed on approve/reject then matches) - C.
hitl_meta = interrupt_hitl_meta(interrupts)
ctx = dict(reply_context or {})
if hitl_meta.get("allowed_decisions"):
ctx[HITL_META_KEY] = {
"allowed_decisions": hitl_meta["allowed_decisions"], "kind": hitl_meta.get("kind"),
"timeout_default": hitl_meta.get("timeout_default"),
}
async with SessionLocal() as s:
await HandoffService.create(
s, channel=ch, tenant_id=ch.tenant_id, project_id=ch.project_id,
workflow_id=result.get("workflow_id"), run_id=result.get("run_id"),
thread_id=result.get("thread_id"), customer=customer, customer_message=customer_message,
reason=reason, reply_context=ctx,
)
return interrupt_ack(interrupts) or "A team member will follow up with you shortly."
@public.post("/v1/channels/email/{key}/inbound")
async def email_inbound(key: str, request: Request, run_service: RunService = Depends(get_run_service)):
ch, workflow_id = await _resolve("email", key)
if not rate_limiter.allow(f"email:{key}", rate=120, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "rate limit exceeded")
try:
payload = await request.json()
except Exception: # noqa: BLE001 - provider posts form-encoded
form = await request.form()
payload = dict(form)
parsed = email_ch.parse_inbound(payload)
text = email_ch.build_input_text(parsed, include_subject=(ch.config or {}).get("include_subject", True))
# Continue the same email thread across replies so the conversation keeps context (F6).
conv_key = parsed.get("thread_ref") or (parsed.get("from_addr") or None)
result = await dispatch_message(run_service, tenant_id=ch.tenant_id, project_id=ch.project_id,
workflow_id=workflow_id, text=text, conversation_key=conv_key,
source="channel_email")
ack = await _maybe_open_handoff(ch, result, customer=parsed.get("from_addr"), customer_message=text, reply_context=parsed)
reply_text = ack or result.get("answer")
delivered = None
if (ch.config or {}).get("reply", True) and reply_text:
try:
# send_reply now retries with backoff and returns whether an email was actually sent
# (False = SMTP not configured); a failure raises so we can record it (audit E).
delivered = await email_ch.send_reply(ch, parsed, reply_text)
except Exception: # noqa: BLE001 - reply delivery failure shouldn't 500 the webhook
log.warning("email reply delivery failed for channel %s", ch.id, exc_info=True)
delivered = False
return {"ok": True, "handoff": bool(ack), "delivered": delivered}
+131
View File
@@ -0,0 +1,131 @@
"""UI component endpoints (CRUD) - Feature 2 (generative UI).
A Component is a saved HTML/CSS template + declarative button actions + a JSON-Schema
for its props. It is attached to agents like a tool (agent config["components"]) and
rendered client-side. DTOs are defined inline since the shape is self-contained.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.dto import ExportIn, ImportIn, ImportReport
from forge.services.components import ComponentService
from forge.services.portability import PortabilityService
from forge.services.versions import safe_snapshot
router = APIRouter(prefix="/v1/projects/{project_id}/components", tags=["components"])
class ComponentCreate(BaseModel):
# Used verbatim as the LLM tool name → must match the provider-safe identifier charset
# (audit M3), else the call fails at request time on real providers.
name: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,64}$")
title: str | None = None
description: str = ""
props_schema: dict[str, Any] = Field(default_factory=dict)
html: str = ""
css: str = ""
actions: list[dict[str, Any]] = Field(default_factory=list)
sample_props: dict[str, Any] = Field(default_factory=dict)
kind: str = "html"
class ComponentUpdate(BaseModel):
name: str | None = Field(default=None, pattern=r"^[a-zA-Z0-9_-]{1,64}$")
title: str | None = None
description: str | None = None
props_schema: dict[str, Any] | None = None
html: str | None = None
css: str | None = None
actions: list[dict[str, Any]] | None = None
sample_props: dict[str, Any] | None = None
enabled: bool | None = None
class ComponentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
title: str | None = None
description: str = ""
props_schema: dict[str, Any] = Field(default_factory=dict)
html: str = ""
css: str = ""
actions: list[dict[str, Any]] = Field(default_factory=list)
sample_props: dict[str, Any] = Field(default_factory=dict)
kind: str = "html"
enabled: bool = True
version: int = 1
@router.get("", response_model=list[ComponentOut])
async def list_components(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await ComponentService.list(session, tenant_id, project_id)
@router.post("/export")
async def export_components(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Serialize the selected components (HTML/CSS/props/actions) into a downloadable bundle."""
return await PortabilityService.export(session, tenant_id, project_id, "component", body.ids)
@router.post("/import", response_model=ImportReport)
async def import_components(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
"""Create components from an uploaded bundle in THIS project (auto-renamed on collision)."""
if body.type not in (None, "component"):
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
try:
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
except ValueError as e:
raise HTTPException(422, str(e)) from e
@router.post("", response_model=ComponentOut, status_code=201)
async def create_component(project_id: str, body: ComponentCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
existing = await ComponentService.list(session, tenant_id, project_id)
if any(c.name == body.name for c in existing):
raise HTTPException(409, f"A component named '{body.name}' already exists in this project.")
comp = await ComponentService.create(session, tenant_id, project_id, **body.model_dump())
await safe_snapshot(session, "component", comp, author=user)
return comp
@router.get("/{component_id}", response_model=ComponentOut)
async def get_component(project_id: str, component_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
if comp is None:
raise HTTPException(404, "Component not found")
return comp
@router.patch("/{component_id}", response_model=ComponentOut)
async def update_component(project_id: str, component_id: str, body: ComponentUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
if comp is None:
raise HTTPException(404, "Component not found")
if body.name and body.name != comp.name:
existing = await ComponentService.list(session, tenant_id, project_id)
if any(c.name == body.name and c.id != comp.id for c in existing):
raise HTTPException(409, f"A component named '{body.name}' already exists in this project.")
comp = await ComponentService.update(session, comp, **body.model_dump(exclude_unset=True))
await safe_snapshot(session, "component", comp, author=user)
return comp
@router.delete("/{component_id}", status_code=204)
async def delete_component(project_id: str, component_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
if comp is None:
raise HTTPException(404, "Component not found")
await ComponentService.delete(session, comp)
+89
View File
@@ -0,0 +1,89 @@
"""Self-service per-user credentials ("connections") — a connector-safe surface.
A per-user auth provider (config.per_user_context_keys = ["end_user_id"]) has NO shared secret;
each end user supplies their OWN downstream token and a tool then acts as them. These routes let
ANY logged-in user — down to the least-privileged `connector` role, who never sees the Auth
Providers admin — list the per-user providers they must connect and set/clear their own token.
Deliberately separate from the `/auth-providers` admin router: it returns only minimal fields (no
provider config / secret refs) and is gated at "any real logged-in user", so a connector needs no
access to the auth-provider admin surface. Keyed server-side by the CALLER's user id — the same
identity an MCP PAT resolves to — so the token a user pastes here is exactly what gets injected on
tool calls made as them. Setting a credential ON BEHALF OF another user stays editor-gated on the
`/auth-providers/{ap_id}/connections/{end_user_id}` routes.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_current_user, get_session
from forge.schemas.dto import UserConnectionIn
from forge.services.auth_providers import AuthProviderService
router = APIRouter(prefix="/v1/projects/{project_id}/connections", tags=["connections"])
def _require_real_user(user: CurrentUser) -> None:
# A per-user credential is keyed by the caller's stable user id. Reject only shared machine
# principals (service token / API key) which carry no per-user identity; a logged-in user
# (any role, incl. connector) AND the auth-off dev user both key fine.
if str(user.id).startswith(("apikey:", "service")):
raise HTTPException(status.HTTP_403_FORBIDDEN, "per-user credentials require a user identity")
def _is_per_user(ap) -> bool:
return "end_user_id" in ((ap.config or {}).get("per_user_context_keys") or [])
async def _load_per_user(session, tenant_id: str, ap_id: str, user: CurrentUser):
_require_real_user(user)
ap = await AuthProviderService.get(session, tenant_id, ap_id)
if ap is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "auth provider not found")
if not _is_per_user(ap):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "auth provider is not per-user")
return ap
@router.get("")
async def list_my_connections(project_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
"""The per-user providers this project defines, each with whether the CALLER has connected it.
Minimal fields only (no config / secret refs) — safe for the connector home page."""
_require_real_user(user)
aps = await AuthProviderService.list(session, tenant_id, project_id)
out = []
for ap in aps:
if not _is_per_user(ap):
continue
st = await AuthProviderService.get_user_connection(tenant_id, project_id, ap, user.id)
out.append({"id": ap.id, "name": ap.name, "kind": ap.kind, "connected": bool(st.get("connected"))})
return out
@router.get("/{ap_id}")
async def my_connection_status(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
ap = await _load_per_user(session, tenant_id, ap_id, user)
return await AuthProviderService.get_user_connection(tenant_id, project_id, ap, user.id)
@router.put("/{ap_id}", status_code=204)
async def set_my_connection(project_id: str, ap_id: str, body: UserConnectionIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
ap = await _load_per_user(session, tenant_id, ap_id, user)
bundle = {"access_token": body.access_token, **(body.extra or {})}
if body.refresh_token:
bundle["refresh_token"] = body.refresh_token
if body.expires_at is not None:
bundle["expires_at"] = body.expires_at
await AuthProviderService.set_user_connection(session, tenant_id, project_id, ap, user.id, bundle=bundle)
@router.delete("/{ap_id}", status_code=204)
async def clear_my_connection(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
ap = await _load_per_user(session, tenant_id, ap_id, user)
await AuthProviderService.clear_user_connection(session, tenant_id, project_id, ap, user.id)
+80
View File
@@ -0,0 +1,80 @@
"""Conversation-centric Traces endpoints.
A conversation = one chat session (Thread); each turn is a Trace carrying the user
message, the AI response, and the actor (user name / "System"). This powers the
Traces screen: sessions grouped by end user, their turns, and the filter facets.
The per-turn span waterfall is still served by GET .../traces/{trace_id}.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import current_tenant_id, get_session, require_role
from forge.schemas.dto import ConversationDetailOut, ConversationOut, FacetsOut, TurnOut
from forge.services.conversations import ConversationService, summarize
router = APIRouter(prefix="/v1/projects/{project_id}/conversations", tags=["conversations"])
def _turn(t) -> TurnOut:
return TurnOut(
trace_id=t.id, run_id=t.run_id, source=t.source or "", user_message=t.user_message,
ai_response=t.ai_response, status=t.status, error=t.error, latency_ms=t.latency_ms,
total_tokens=t.total_tokens, total_cost_usd=t.total_cost_usd, started_at=t.started_at,
)
@router.get("", response_model=list[ConversationOut])
async def list_conversations(
project_id: str,
actor: str | None = Query(None, description="Filter by user name (e.g. 'System', 'Unknown user')"),
source: str | None = Query(None, description="Filter by origin (playground|api|embed|channel_*|…)"),
status: str | None = Query(None, description="'error' or 'success'/'done'"),
search: str | None = Query(None, description="Match user/AI message text (any turn)"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
convos = await ConversationService.list(
session, tenant_id, project_id, actor=actor, source=source, status=status,
search=search, limit=limit, offset=offset,
)
return [ConversationOut.model_validate(c) for c in convos]
@router.get("/facets", response_model=FacetsOut)
async def conversation_facets(
project_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
return await ConversationService.facets(session, tenant_id, project_id)
@router.post("/purge")
async def purge_conversations(
project_id: str,
older_than_days: int = Query(..., ge=0, description="Delete traces + spans older than this many days"),
session: AsyncSession = Depends(get_session),
_admin=Depends(require_role("admin")),
tenant_id: str = Depends(current_tenant_id),
):
"""Manual retention control (admin only). Nothing is auto-deleted."""
removed = await ConversationService.purge_older_than(session, tenant_id, project_id, older_than_days)
return {"removed": removed}
@router.get("/{thread_id}", response_model=ConversationDetailOut)
async def get_conversation(
project_id: str,
thread_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
turns = await ConversationService.turns(session, tenant_id, project_id, thread_id)
if not turns:
raise HTTPException(404, "Conversation not found")
return {"conversation": summarize(thread_id, turns), "turns": [_turn(t) for t in turns]}
+106
View File
@@ -0,0 +1,106 @@
"""Embed / identity endpoints (Phase 3b).
Mint a short-lived, signed SESSION TOKEN that carries a verified `end_user` for the browser
widget. Called server-to-server by the integrator's authenticated backend (which already
authenticated the user); the widget then sends the token on each run so Forge trusts the
identity without trusting the browser. The signing secret never reaches the client.
"""
from __future__ import annotations
import secrets as _secrets
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, get_session, require_role
from forge.schemas.dto import EndUser
from forge.security import create_session_token
from forge.services.projects import ProjectService
router = APIRouter(prefix="/v1/projects/{project_id}", tags=["embed"])
class SessionTokenIn(BaseModel):
end_user: EndUser
ttl_minutes: int = Field(default=30, ge=1, le=720)
# Origins the widget is allowed to run from (carried as a claim for the widget transport;
# see the deferred per-origin CORS work). Empty = unrestricted by token.
origins: list[str] = Field(default_factory=list)
class SessionTokenOut(BaseModel):
token: str
expires_in: int # seconds
@router.post("/session-tokens", response_model=SessionTokenOut)
async def mint_session_token(
project_id: str,
body: SessionTokenIn,
user: CurrentUser = Depends(require_role("editor")),
) -> SessionTokenOut:
token = create_session_token(
tenant_id=user.tenant_id,
project_id=project_id,
end_user=body.end_user.model_dump(exclude_none=True),
origins=body.origins,
ttl_minutes=body.ttl_minutes,
)
return SessionTokenOut(token=token, expires_in=body.ttl_minutes * 60)
# --- embeddable widget settings (publishable key + allowed origins + workflow) ---
class EmbedSettingsIn(BaseModel):
enabled: bool = True
allowed_origins: list[str] = Field(default_factory=list)
workflow_id: str | None = None
class EmbedSettingsOut(BaseModel):
enabled: bool
allowed_origins: list[str]
workflow_id: str | None = None
publishable_key: str | None = None
embed_src: str | None = None
def _embed_out(project) -> EmbedSettingsOut:
e = (project.config or {}).get("embed") or {}
key = project.embed_key
enabled = bool(e.get("enabled"))
return EmbedSettingsOut(
enabled=enabled,
allowed_origins=e.get("allowed_origins") or [],
workflow_id=e.get("workflow_id"),
publishable_key=key,
embed_src=(f"/embed?key={key}" if key and enabled else None),
)
@router.get("/embed", response_model=EmbedSettingsOut)
async def get_embed(project_id: str, session: AsyncSession = Depends(get_session), user: CurrentUser = Depends(require_role("editor"))):
proj = await ProjectService.get(session, user.tenant_id, project_id)
if proj is None:
raise HTTPException(404, "Project not found")
return _embed_out(proj)
@router.put("/embed", response_model=EmbedSettingsOut)
async def set_embed(project_id: str, body: EmbedSettingsIn, session: AsyncSession = Depends(get_session), user: CurrentUser = Depends(require_role("editor"))):
proj = await ProjectService.get(session, user.tenant_id, project_id)
if proj is None:
raise HTTPException(404, "Project not found")
if proj.embed_key is None:
proj.embed_key = "pk_" + _secrets.token_urlsafe(24)
cfg = dict(proj.config or {})
cfg["embed"] = {
"enabled": body.enabled,
"allowed_origins": [o.strip() for o in (body.allowed_origins or []) if o.strip()],
"workflow_id": body.workflow_id,
}
proj.config = cfg
await session.commit()
await session.refresh(proj)
return _embed_out(proj)
+179
View File
@@ -0,0 +1,179 @@
"""Public embed transport (Phase 3b/4).
Key-gated, NOT platform-JWT-authenticated endpoints the chat widget calls. The project is
resolved by its publishable key; the widget can only run the project's configured workflow,
as an anonymous end user or one verified by a server-minted session token. Rate-limited per
key. The widget is served same-origin from /embed, so these are same-origin calls (no CORS).
Embedding-site restriction is enforced by the /embed page's `frame-ancestors` CSP, set from
the project's allowed_origins (see the web middleware).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from forge.config import settings
from forge.deps import client_ip, get_run_service, get_session
from forge.models import Project, Workflow
from forge.security import TokenError, decode_token
from forge.services.components import ComponentService
from forge.services.runs import RunService
from forge.util.ratelimit import rate_limiter
router = APIRouter(prefix="/v1/embed/{key}", tags=["embed-public"])
SSE_HEADERS = {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
def _embed_rate_limit(key: str, ip: str | None, *, per_min: int, ip_per_min: int) -> None:
"""The publishable key is PUBLIC, so the real cost/abuse ceilings are the per-IP and
per-key limits (audit S2). Raises 429 when either bucket is empty. per_min/ip_per_min
of 0 => that bucket is unlimited."""
if per_min and not rate_limiter.allow(f"embed:{key}", rate=per_min, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many requests; slow down")
if ip_per_min and not rate_limiter.allow(f"embed-ip:{key}:{ip or 'unknown'}", rate=ip_per_min, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many requests from your network; slow down")
async def _project(session: AsyncSession, key: str) -> Project:
proj = (await session.execute(select(Project).where(Project.embed_key == key))).scalar_one_or_none()
if proj is None or not ((proj.config or {}).get("embed") or {}).get("enabled"):
raise HTTPException(status.HTTP_404_NOT_FOUND, "embed not found or disabled")
return proj
async def _workflow_id(session: AsyncSession, proj: Project) -> str:
e = (proj.config or {}).get("embed") or {}
if e.get("workflow_id"):
return e["workflow_id"]
rows = (await session.execute(select(Workflow).where(Workflow.project_id == proj.id))).scalars().all()
active = next((w for w in rows if w.status == "active"), None) or (rows[0] if rows else None)
if active is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no workflow configured for this embed")
return active.id
class EmbedConfigOut(BaseModel):
name: str
allowed_origins: list[str] = []
class EmbedRunIn(BaseModel):
input: dict | None = None
thread_id: str | None = None
session_token: str | None = None
@router.get("/config", response_model=EmbedConfigOut)
async def embed_config(key: str, session: AsyncSession = Depends(get_session)):
proj = await _project(session, key)
e = (proj.config or {}).get("embed") or {}
# workflow_id is resolved server-side from the key on each run - the widget never needs it, so
# don't leak the internal id to the anonymous client (audit L).
return EmbedConfigOut(name=proj.name, allowed_origins=e.get("allowed_origins") or [])
@router.get("/components")
async def embed_components(key: str, session: AsyncSession = Depends(get_session)):
proj = await _project(session, key)
comps = await ComponentService.list(session, proj.tenant_id, proj.id)
return [{"id": c.id, "name": c.name, "html": c.html, "css": c.css, "actions": c.actions} for c in comps]
@router.post("/runs")
async def embed_create_run(key: str, body: EmbedRunIn, request: Request, session: AsyncSession = Depends(get_session), run_service: RunService = Depends(get_run_service)):
proj = await _project(session, key)
_embed_rate_limit(
key, client_ip(request),
per_min=settings.embed_rate_limit_per_minute,
ip_per_min=settings.embed_rate_limit_per_ip_per_minute,
)
wid = await _workflow_id(session, proj)
# Identity: only from a verified session token (the browser can't assert it); else anonymous.
end_user = None
if body.session_token:
try:
claims = decode_token(body.session_token, expected_type="session")
except TokenError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid session token: {e}") from e
if claims.get("tid") != proj.tenant_id or claims.get("pid") != proj.id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "session token is not valid for this embed")
end_user = claims.get("end_user") or None
# The public surface is anonymous and uncapped by design, so it MUST also honor the
# tenant daily quota (audit S2) - otherwise the widget bypasses the only spend ceiling.
from forge.services.budget import BudgetExceeded, ModelNotAllowed
from forge.services.quota import QuotaExceeded, run_admission
try:
async with run_admission(session, proj.tenant_id):
run = await run_service.create_run(
session, tenant_id=proj.tenant_id, project_id=proj.id, workflow_id=wid,
input=body.input or {}, thread_id=body.thread_id, end_user=end_user, source="embed",
)
except QuotaExceeded as e:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
except (BudgetExceeded, ModelNotAllowed) as e:
# Anonymous embed surface: budget/model errors are hidden as a generic 402 (no internal
# detail to the browser end user), logged operator-side by the run machinery.
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, "This assistant is temporarily unavailable.") from e
return {"id": run.id, "thread_id": run.thread_id}
@router.get("/runs/{run_id}/stream")
async def embed_stream(
key: str, run_id: str, request: Request,
session: AsyncSession = Depends(get_session),
run_service: RunService = Depends(get_run_service),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
):
proj = await _project(session, key)
_embed_rate_limit(
key, client_ip(request),
per_min=0, # connection churn is bounded per-IP below, not per-key
ip_per_min=settings.embed_stream_limit_per_ip_per_minute,
)
# A dropped widget connection resumes here: the browser's EventSource resends Last-Event-ID,
# so we replay missed frames then follow live - the run itself kept executing (finding #12).
start_from = int(last_event_id) if (last_event_id or "").isdigit() else 0
async def gen():
# Scope by BOTH tenant and project (audit S1) so a publishable key can't stream
# another project's runs; public=True hides internal error detail / operator data.
# run_context is NOT taken from the anonymous browser: X-Forge-Context is a trusted
# server-side caller channel, so honoring it here would let an end user forge {{ctx.*}}
# values injected into outbound tool requests (audit M4).
async for frame in run_service.stream(
run_id=run_id, tenant_id=proj.tenant_id, project_id=proj.id, public=True,
run_context=None, last_event_id=start_from,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
return EventSourceResponse(gen(), headers=SSE_HEADERS)
class EmbedResumeIn(BaseModel):
value: Any = True
@router.post("/runs/{run_id}/resume")
async def embed_resume(key: str, run_id: str, body: EmbedResumeIn, request: Request, session: AsyncSession = Depends(get_session), run_service: RunService = Depends(get_run_service)):
"""Resume an interrupted (human-in-the-loop) run from the widget - mirrors the authed
resume endpoint but resolves the tenant+project from the publishable key. resume() is
scoped to this project (audit S1); the end-user identity is already bound to the thread
(thread.meta.end_user) from the original run, so a value-only body matches the authed
resume exactly."""
proj = await _project(session, key)
_embed_rate_limit(
key, client_ip(request),
per_min=settings.embed_rate_limit_per_minute,
ip_per_min=settings.embed_rate_limit_per_ip_per_minute,
)
# public=True redacts internal error detail (M6) and returns only the final assistant message
# (H5); run_context=None so the anonymous caller can't inject {{ctx.*}} tool values (M4).
return await run_service.resume(run_id=run_id, tenant_id=proj.tenant_id, value=body.value, project_id=proj.id, run_context=None, public=True)
+170
View File
@@ -0,0 +1,170 @@
"""Evaluation datasets: CRUD + run (quality + regression) + persisted run history."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_run_service, get_session, require_role, run_context
from forge.services.evals import EvalService
from forge.services.runs import RunService
router = APIRouter(prefix="/v1/projects/{project_id}/datasets", tags=["evals"])
class DatasetIn(BaseModel):
name: str
workflow_id: str | None = None
score_mode: str = "contains" # contains|exact|regex|numeric|json|embedding|judge
items: list[dict] = []
class RunIn(BaseModel):
# Publish-time regression gate (finding F2): flag/block when the pass rate drops below the
# dataset's previous rate, or below an absolute floor. Off by default (a plain quality run).
regression_gate: bool = False
min_pass_rate: float | None = None
def _out(d) -> dict:
return {"id": d.id, "name": d.name, "workflow_id": d.workflow_id, "score_mode": d.score_mode,
"items": d.items, "n_items": len(d.items or []), "last_pass_rate": d.last_pass_rate}
def _run_out(r) -> dict:
return {"id": r.id, "created_at": r.created_at, "dataset_id": r.dataset_id, "workflow_id": r.workflow_id,
"score_mode": r.score_mode, "status": r.status, "total": r.total, "passed": r.passed,
"pass_rate": r.pass_rate, "prev_pass_rate": r.prev_pass_rate, "regressed": r.regressed,
"total_tokens": r.total_tokens, "total_cost_usd": r.total_cost_usd, "meta": r.meta}
def _result_out(r) -> dict:
return {"id": r.id, "item_index": r.item_index, "input": r.input, "expected": r.expected,
"answer": r.answer, "passed": r.passed, "score": r.score, "status": r.status,
"reason": r.reason, "checks": r.checks}
def _needs_judge(ds) -> bool:
"""A judge model is needed if the dataset scores by judge, or any item has a judge assertion."""
if ds.score_mode == "judge":
return True
return any((a.get("type") or "").lower() == "judge"
for item in (ds.items or []) for a in (item.get("assertions") or []))
@router.get("")
async def list_datasets(project_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
return [_out(d) for d in await EvalService.list(session, tenant_id, project_id)]
@router.post("", status_code=201)
async def create_dataset(project_id: str, body: DatasetIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ds = await EvalService.create(session, tenant_id, project_id, name=body.name, workflow_id=body.workflow_id,
score_mode=body.score_mode, items=body.items)
return _out(ds)
@router.patch("/{dataset_id}")
async def update_dataset(project_id: str, dataset_id: str, body: DatasetIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ds = await EvalService.get(session, tenant_id, dataset_id)
if not ds:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
ds = await EvalService.update(session, ds, name=body.name, workflow_id=body.workflow_id,
score_mode=body.score_mode, items=body.items)
return _out(ds)
@router.delete("/{dataset_id}")
async def delete_dataset(project_id: str, dataset_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
ds = await EvalService.get(session, tenant_id, dataset_id)
if not ds:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
await EvalService.delete(session, ds)
return {"ok": True}
@router.get("/{dataset_id}/runs")
async def list_eval_runs(project_id: str, dataset_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Persisted eval-run history for a dataset (newest first) - quality trend + regression view."""
return [_run_out(r) for r in await EvalService.history(session, tenant_id, dataset_id)]
@router.get("/{dataset_id}/runs/{eval_run_id}/results")
async def list_eval_results(project_id: str, dataset_id: str, eval_run_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Per-item outcomes for one persisted eval run (for per-example diffing)."""
return [_result_out(r) for r in await EvalService.results(session, tenant_id, eval_run_id)]
async def _prepare_run(session, *, tenant_id, project_id, dataset_id, user):
"""Shared setup for the batch + streaming run endpoints: load the dataset, resolve a judge
model when needed, and derive the on-behalf-of `end_user` (so per-user tool auth resolves the
launching editor's connected credential, exactly like a Playground run - machine principals
carry no per-user identity and run without one)."""
ds = await EvalService.get(session, tenant_id, dataset_id)
if not ds:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
judge_model = None
if _needs_judge(ds):
from forge.engine.models import resolve_model
from forge.services.runtime import build_compile_context
ctx = await build_compile_context(session, tenant_id=tenant_id, project_id=project_id)
# resolve_model returns the offline fake model when the project has no real model;
# EvalService then reports judge items as "unavailable" rather than grading against it.
judge_model = resolve_model(ctx.default_model, ctx)
end_user = None
if not str(user.id).startswith(("apikey:", "service")):
end_user = {"id": user.id, "email": user.email}
return ds, judge_model, end_user
@router.post("/{dataset_id}/run")
async def run_dataset(project_id: str, dataset_id: str, body: RunIn | None = None,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor")),
rc: dict | None = Depends(run_context),
run_service: RunService = Depends(get_run_service)):
body = body or RunIn()
ds, judge_model, end_user = await _prepare_run(session, tenant_id=tenant_id, project_id=project_id,
dataset_id=dataset_id, user=user)
return await EvalService.run(session, run_service, ds, judge_model=judge_model,
regression_gate=body.regression_gate, min_pass_rate=body.min_pass_rate,
end_user=end_user, run_context=rc)
@router.post("/{dataset_id}/run/stream")
async def run_dataset_stream(project_id: str, dataset_id: str, body: RunIn | None = None,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor")),
rc: dict | None = Depends(run_context),
run_service: RunService = Depends(get_run_service)):
"""Same as /run, but streams SSE frames (start / item / done / error) so the console can
render every case immediately and update each row live as its run finishes."""
import json as _json
from sse_starlette.sse import EventSourceResponse
body = body or RunIn()
ds, judge_model, end_user = await _prepare_run(session, tenant_id=tenant_id, project_id=project_id,
dataset_id=dataset_id, user=user)
async def gen():
async for frame in EvalService.stream(
run_service, ds, judge_model=judge_model, regression_gate=body.regression_gate,
min_pass_rate=body.min_pass_rate, end_user=end_user, run_context=rc,
):
yield {"event": frame["event"], "data": _json.dumps(frame["data"], default=str)}
return EventSourceResponse(gen(), headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"})
+58
View File
@@ -0,0 +1,58 @@
"""Live-agent inbox: list open handoffs and reply (resumes the run + pushes the answer)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import (
CurrentUser,
current_tenant_id,
get_run_service,
get_session,
require_role,
)
from forge.models import HandoffRequest
from forge.services.handoff import HandoffService
from forge.services.runs import RunService
router = APIRouter(prefix="/v1/projects/{project_id}/handoffs", tags=["handoff"])
class ReplyIn(BaseModel):
message: str
def _out(h: HandoffRequest) -> dict:
return {
"id": h.id, "run_id": h.run_id, "workflow_id": h.workflow_id, "channel_id": h.channel_id,
"customer": h.customer, "customer_message": h.customer_message, "reason": h.reason,
"status": h.status, "agent_id": h.agent_id,
"at": h.created_at.isoformat() if h.created_at else None,
}
@router.get("")
async def list_handoffs(project_id: str, status: str = "open",
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("viewer"))):
return [_out(h) for h in await HandoffService.list(session, tenant_id, project_id, status=status or None)]
@router.post("/{handoff_id}/reply")
async def reply_handoff(project_id: str, handoff_id: str, body: ReplyIn,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor")),
run_service: RunService = Depends(get_run_service)):
h = (await session.execute(
select(HandoffRequest).where(HandoffRequest.tenant_id == tenant_id, HandoffRequest.id == handoff_id)
)).scalar_one_or_none()
if h is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "handoff not found")
if h.status != "open":
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"handoff is already {h.status}")
return await HandoffService.reply(session, run_service, handoff=h, agent_id=user.id, message=body.message)
+123
View File
@@ -0,0 +1,123 @@
"""Health, readiness, version, and Prometheus metrics."""
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse, PlainTextResponse
from sqlalchemy import text
import forge
from forge.config import settings
from forge.db import SessionLocal
from forge.util.metrics import snapshot
router = APIRouter(tags=["meta"])
def _v(pkg: str) -> str:
try:
return version(pkg)
except PackageNotFoundError:
return "not-installed"
@router.get("/health")
@router.get("/livez")
async def health() -> dict:
"""Liveness: the process is up and serving. Cheap and dependency-free."""
return {"status": "ok"}
def _ping_redis() -> str:
"""Ping Redis and confirm a worker heartbeat when arq is in use. Returns an 'ok'/'error'/…
status string; only called when FORGE_REDIS_URL is set."""
try:
import redis
client = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
client.ping()
except Exception as e: # noqa: BLE001
return f"error: {type(e).__name__}"
return "ok"
def _worker_status() -> str:
"""Best-effort arq worker liveness via its health-check key in Redis (arq writes
`arq:health-check`). 'missing' means Redis is up but no worker has checked in."""
try:
import redis
client = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
return "ok" if client.exists("arq:health-check") else "missing"
except Exception as e: # noqa: BLE001
return f"error: {type(e).__name__}"
def _vector_status() -> str:
try:
import chromadb
chromadb.PersistentClient(path=settings.chroma_path).heartbeat()
except Exception as e: # noqa: BLE001
return f"error: {type(e).__name__}"
return "ok"
@router.get("/readyz")
async def readyz(request: Request) -> JSONResponse:
"""Readiness: can this instance actually serve traffic? Checks the DB, the durable
checkpointer, and (when configured) Redis, the vector store, and a worker heartbeat. Returns
503 when a GATING dependency is down so a load balancer / k8s probe routes around it
(audit P-imp / finding k). The worker is reported but non-gating - the API can still serve
while the worker tier is down (runs queue)."""
checks: dict[str, str] = {}
try:
async with SessionLocal() as s:
await s.execute(text("SELECT 1"))
checks["db"] = "ok"
except Exception as e: # noqa: BLE001
checks["db"] = f"error: {type(e).__name__}"
checks["checkpointer"] = "ok" if getattr(request.app.state, "checkpointer", None) is not None else "missing"
checks["vector_store"] = _vector_status() # reported; non-gating (API serves w/o knowledge)
# Gating checks: everything that must be healthy for this instance to serve.
gating = ["db", "checkpointer"]
if settings.redis_url:
checks["redis"] = _ping_redis()
checks["worker"] = _worker_status() # reported, not gating
gating.append("redis")
ready = all(checks.get(k) == "ok" for k in gating)
return JSONResponse({"ready": ready, "checks": checks}, status_code=200 if ready else 503)
@router.get("/metrics")
async def metrics() -> PlainTextResponse:
"""Prometheus text-format exposition of the in-process counters so operators can scrape
them (per-worker; aggregate across replicas at the scraper). Complements the OTLP trace
export. No new dependency - rendered directly from the counter snapshot.
Gated behind `settings.expose_metrics` (default off): the counters are an internal
operational surface, so keep it disabled on public deployments and enable only where the
scrape endpoint is reachable from a trusted network."""
if not settings.expose_metrics:
raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
lines: list[str] = []
for name, value in sorted(snapshot().items()):
metric = "forge_" + "".join(c if (c.isalnum() or c == "_") else "_" for c in name)
lines.append(f"# TYPE {metric} counter")
lines.append(f"{metric} {value}")
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain; version=0.0.4")
@router.get("/version")
async def version_info() -> dict:
# Dependency versions aid fingerprinting; gate behind the same operator-only switch as /metrics.
if not settings.expose_metrics:
raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
return {
"name": "forge-api",
"version": forge.__version__,
"langchain": _v("langchain"),
"langgraph": _v("langgraph"),
}
+190
View File
@@ -0,0 +1,190 @@
"""Public inbound webhook endpoint for `webhook_in` triggers.
Authenticated by the unguessable per-trigger key in the path (+ optional HMAC
signature). No JWT - external systems POST here. Rate-limited per trigger.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import time
from fastapi import APIRouter, Depends, HTTPException, Request, status
from forge.db.base import SessionLocal
from forge.deps import get_run_service
from forge.secrets.store import SecretStore
from forge.services.dispatch import dispatch_trigger
from forge.services.runs import RunService
from forge.services.triggers import TriggerService
from forge.util.ratelimit import idempotency, rate_limiter
from forge.util.tasks import spawn
log = logging.getLogger("forge.hooks")
router = APIRouter(prefix="/v1/hooks", tags=["hooks"])
# Delivery-id headers common providers send for at-least-once retries; used to dedupe so a
# provider re-delivery doesn't double-run the workflow (duplicate side effects). A trigger may
# name its own via config.dedupe_header, or opt into body-hash dedupe via config.dedupe_body.
_DELIVERY_HEADERS = ("X-GitHub-Delivery", "X-Request-Id", "Idempotency-Key", "X-Delivery-Id", "X-Event-Id")
# Default replay window (seconds) for signature schemes that sign a timestamp (Stripe/Slack).
# A signature older/newer than this is rejected so a captured request can't be replayed later.
# Per-trigger override: config.signature_tolerance_seconds.
_SIG_TOLERANCE_DEFAULT = 300
def _dedupe_key(trigger, request: Request, raw: bytes) -> str | None:
cfg = trigger.config or {}
names = [cfg["dedupe_header"]] if cfg.get("dedupe_header") else list(_DELIVERY_HEADERS)
for name in names:
if not name:
continue
v = request.headers.get(name) or request.headers.get(name.lower())
if v:
return v.strip()
if cfg.get("dedupe_body"):
return hashlib.sha256(raw).hexdigest()
return None
def _hmac_hex(secret: str, payload: bytes) -> str:
return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
def _within_tolerance(ts_str: str | None, tolerance: int) -> bool:
"""True if a signed unix timestamp is within `tolerance` seconds of now (replay guard).
`tolerance <= 0` disables the check (accept any timestamp)."""
if tolerance <= 0:
return True
try:
ts = float(str(ts_str).strip())
except (TypeError, ValueError):
return False
return abs(time.time() - ts) <= tolerance
def _parse_kv_header(value: str) -> dict[str, str]:
"""Parse a `k=v,k=v` header (Stripe-Signature) -> {k: last-v}. Multiple values for one key
(e.g. several v1=) are handled by the caller via the raw header; this keeps the last."""
out: dict[str, str] = {}
for part in (value or "").split(","):
if "=" in part:
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def _verify_stripe(secret: str, request: Request, body: bytes, tolerance: int) -> bool:
# Stripe-Signature: t=<unix>,v1=<hexmac>[,v1=<hexmac>]. Signed payload = "<t>.<body>".
header = request.headers.get("Stripe-Signature") or request.headers.get("stripe-signature")
if not header:
return False
fields = _parse_kv_header(header)
t = fields.get("t")
if not _within_tolerance(t, tolerance):
return False
expected = _hmac_hex(secret, f"{t}.".encode() + body)
provided = [p.split("=", 1)[1].strip() for p in header.split(",") if p.strip().startswith("v1=")]
return any(hmac.compare_digest(expected, p) for p in provided)
def _verify_slack(secret: str, request: Request, body: bytes, tolerance: int) -> bool:
# X-Slack-Signature: v0=<hexmac>; base string = "v0:<ts>:<body>"; ts from X-Slack-Request-Timestamp.
ts = request.headers.get("X-Slack-Request-Timestamp") or request.headers.get("x-slack-request-timestamp")
provided = request.headers.get("X-Slack-Signature") or request.headers.get("x-slack-signature")
if not ts or not provided or not _within_tolerance(ts, tolerance):
return False
expected = "v0=" + _hmac_hex(secret, b"v0:" + ts.encode() + b":" + body)
return hmac.compare_digest(expected, provided.strip())
def _verify_hmac_sha256(secret: str, request: Request, body: bytes) -> bool:
# Default / GitHub-style: HMAC-SHA256 over the raw body; header tolerates a "sha256=" prefix.
signature = request.headers.get("x-forge-signature") or request.headers.get("X-Hub-Signature-256")
if not signature:
return False
expected = _hmac_hex(secret, body)
provided = signature.split("=", 1)[-1].strip() # tolerate "sha256=<hex>"
return hmac.compare_digest(expected, provided)
async def _verify_signature(trigger, request: Request, body: bytes) -> bool:
"""Verify the inbound signature under the trigger's configured scheme (audit I).
Schemes (config.signature_scheme): "hmac_sha256" (default; GitHub-style, raw-body HMAC),
"stripe" (t=,v1= over "<t>.<body>"), "slack" (v0: over "v0:<ts>:<body>"). Stripe/Slack also
enforce a signed-timestamp tolerance window (config.signature_tolerance_seconds, default 300)
to block replays."""
cfg = trigger.config or {}
if not cfg.get("require_signature"):
return True
if not cfg.get("secret_ref"):
return False
try:
secret = str(await SecretStore().read_ref(
tenant_id=trigger.tenant_id, project_id=trigger.project_id, ref=cfg["secret_ref"]
))
except Exception: # noqa: BLE001
return False
scheme = (cfg.get("signature_scheme") or "hmac_sha256").lower()
tolerance = int(cfg.get("signature_tolerance_seconds", _SIG_TOLERANCE_DEFAULT))
try:
if scheme == "stripe":
return _verify_stripe(secret, request, body, tolerance)
if scheme == "slack":
return _verify_slack(secret, request, body, tolerance)
if scheme in ("hmac_sha256", "hmac", "github", "default"):
return _verify_hmac_sha256(secret, request, body)
except Exception: # noqa: BLE001 - a malformed header must fail closed, not 500
log.warning("signature verification error for trigger %s (scheme=%s)", trigger.id, scheme, exc_info=True)
return False
log.warning("unknown signature_scheme %r for trigger %s", scheme, trigger.id)
return False
@router.post("/{key}")
async def inbound_webhook(
key: str,
request: Request,
wait: bool = False,
run_service: RunService = Depends(get_run_service),
):
async with SessionLocal() as s:
trigger = await TriggerService.by_key(s, key)
if trigger is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown or disabled webhook")
if not rate_limiter.allow(f"hook:{key}", rate=120, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "webhook rate limit exceeded")
raw = await request.body()
if not await _verify_signature(trigger, request, raw):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid signature")
# Idempotency: dedupe an at-least-once redelivery (same delivery id / configured key) so the
# workflow doesn't run twice. Claim the key BEFORE dispatch so concurrent duplicates collapse.
dedupe = _dedupe_key(trigger, request, raw)
if dedupe:
ik = f"hook:{trigger.id}:{dedupe}"
if idempotency.get(ik) is not None:
return {"accepted": True, "trigger": trigger.id, "deduplicated": True}
idempotency.put(ik, {"accepted": True})
try:
payload = await request.json()
except Exception: # noqa: BLE001 - non-JSON body
payload = raw.decode("utf-8", "replace")
if wait:
result = await dispatch_trigger(run_service, trigger, payload)
return result
# fire-and-forget: ack immediately, run in a TRACKED background task so failures are
# logged (not silently swallowed) and a flood can't spawn unbounded coroutines (F4).
accepted = spawn(dispatch_trigger(run_service, trigger, payload), name=f"webhook:{trigger.id}")
if not accepted:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "server busy; retry shortly")
return {"accepted": True, "trigger": trigger.id}
+387
View File
@@ -0,0 +1,387 @@
"""Knowledge endpoints - sources (ingest text/url), Q&A pairs, and a search debugger."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.dto import (
KbSourceCreate,
KbSourceOut,
KnowledgeMapIn,
KnowledgeSearchIn,
QaPairCreate,
QaPairOut,
QaPairUpdate,
RechunkBulkIn,
RechunkIn,
)
from forge.services.knowledge import KnowledgeService, _strip_html
from forge.services.versions import safe_snapshot
from forge.util.tasks import spawn
router = APIRouter(prefix="/v1/projects/{project_id}/knowledge", tags=["knowledge"])
qa_router = APIRouter(prefix="/v1/projects/{project_id}/qa-pairs", tags=["knowledge"])
# Shown on a source when the background ingest task can't be scheduled (in-flight ceiling
# reached). Better to fail the source visibly than leave it stuck "queued" forever.
_QUEUE_FULL_MSG = "ingest queue is full; please retry in a moment"
# Upload DoS bounds (audit M3): cap the in-memory read and the PDF page count so a huge file or a
# small decompression-bomb PDF can't exhaust the process. A global request-body-size limit is best
# enforced at the reverse proxy / uvicorn in front of the app.
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
_MAX_PDF_PAGES = 500
# Extensions we KNOW are binary containers - decoding them as text yields mojibake garbage that
# then gets embedded, so reject with a clear message instead (finding: binary files ingested as
# latin-1). PDFs are handled separately (extractable text); these have no text-decode path here.
_BINARY_EXTS = frozenset({
".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".rtf",
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".ico", ".svgz",
".zip", ".gz", ".tar", ".7z", ".rar", ".bz2", ".xz",
".mp3", ".mp4", ".mov", ".avi", ".wav", ".flac", ".ogg", ".webm", ".mkv",
".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".jar", ".parquet", ".sqlite", ".db",
})
_SUPPORTED_HINT = "Supported: .txt, .md, .pdf, .csv, .json, .html and other UTF-8 text files."
def _csv_to_text(text: str) -> str:
"""CSV -> one header-qualified record per block ("col: value | col: value"), so each row is
self-describing after chunking instead of an opaque comma soup. Falls back to raw text if it
doesn't parse as tabular."""
import csv
import io
try:
rows = list(csv.reader(io.StringIO(text)))
except Exception: # noqa: BLE001 - not valid CSV -> ingest as-is
return text
if len(rows) < 2:
return text
header = [h.strip() for h in rows[0]]
blocks: list[str] = []
for row in rows[1:]:
parts = [f"{(header[i] if i < len(header) else f'col{i + 1}')}: {v}"
for i, v in enumerate(row) if str(v).strip()]
if parts:
blocks.append(" | ".join(parts))
return "\n\n".join(blocks) if blocks else text
def _json_record(item) -> str:
import json
if isinstance(item, dict):
return " | ".join(
f"{k}: {v if isinstance(v, (str, int, float, bool)) else json.dumps(v, ensure_ascii=False)}"
for k, v in item.items()
)
return json.dumps(item, ensure_ascii=False)
def _json_to_text(text: str) -> str:
"""JSON -> one record per block. A list becomes one block per element; a top-level object
with a single list value expands that list (the common {"items": [...]} shape); otherwise the
object is one header-qualified block. Falls back to raw text if it doesn't parse."""
import json
try:
data = json.loads(text)
except Exception: # noqa: BLE001 - not valid JSON -> ingest as-is
return text
if isinstance(data, dict):
list_vals = [v for v in data.values() if isinstance(v, list)]
if len(data) == 1 and list_vals:
data = list_vals[0]
if isinstance(data, list):
blocks = [_json_record(x) for x in data]
return "\n\n".join(b for b in blocks if b.strip()) or text
return _json_record(data)
def _decode_upload(name: str, raw: bytes) -> str:
"""Turn uploaded bytes into ingestible text, or raise 422. Rejects known binary containers
and null-byte binaries (finding: binaries decoded as latin-1 mojibake); strips HTML; parses
CSV/JSON into per-record, header-qualified text (finding: tabular ingested opaquely)."""
import os
ext = os.path.splitext(name.lower())[1]
if ext in _BINARY_EXTS:
raise HTTPException(422, f"'{ext}' files aren't a readable text format. {_SUPPORTED_HINT}")
# Null bytes are the strongest signal of a binary we don't recognize by extension.
if b"\x00" in raw:
raise HTTPException(422, f"File looks binary, not text. {_SUPPORTED_HINT}")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
# Retry as latin-1 ONLY for genuinely-textual non-UTF-8 files; if the result is mostly
# unprintable it was binary after all -> reject rather than embed garbage.
text = raw.decode("latin-1", errors="replace")
printable = sum(c.isprintable() or c.isspace() for c in text)
if not text or printable / len(text) < 0.85:
raise HTTPException(422, f"File isn't a readable text format. {_SUPPORTED_HINT}") from None
if ext in (".html", ".htm"):
text = _strip_html(text)
elif ext == ".csv":
text = _csv_to_text(text)
elif ext == ".json":
text = _json_to_text(text)
if not text.strip():
raise HTTPException(422, "File is empty or not a readable text format.")
return text
async def _queue_ingest(session, tenant_id: str, src, *, reingest: bool = False, label: str = "ingest") -> None:
"""Spawn the background ingest for `src`; if the task ceiling rejects it, mark the source
errored (spawn returns False and closes the coro) so it doesn't sit 'queued' indefinitely."""
if not spawn(KnowledgeService.run_ingest_bg(tenant_id, src.id, reingest=reingest), name=f"{label}:{src.id}"):
await KnowledgeService.mark_error(session, src, _QUEUE_FULL_MSG)
@router.get("/sources", response_model=list[KbSourceOut])
async def list_sources(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await KnowledgeService.list_sources(session, tenant_id, project_id)
@router.post("/sources", response_model=KbSourceOut, status_code=201)
async def add_source(project_id: str, body: KbSourceCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
src = await KnowledgeService.create_source(session, tenant_id, project_id, kind=body.kind, name=body.name, uri=body.uri, text=body.text, folder=body.folder, chunking_strategy=body.chunking_strategy, meta=body.meta)
await safe_snapshot(session, "kb_source", src, author=user, label="added")
# Ingest (fetch/chunk/embed) off the request: a real embedder takes seconds+ for a large
# source, which would time out the HTTP call. The source returns as "queued"; the UI polls.
await _queue_ingest(session, tenant_id, src)
return src
@router.post("/sources/upload", response_model=KbSourceOut, status_code=201)
async def upload_source(
project_id: str,
file: UploadFile = File(...),
folder: str = Form(""),
chunking_strategy: str = Form(""),
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor")),
):
"""Upload a document file (.txt/.md/.pdf and other text formats) into the knowledge base."""
# Bound the in-memory read so an oversized upload can't exhaust memory (audit M3): read one byte
# past the cap to detect oversize without materializing the whole body.
raw = await file.read(_MAX_UPLOAD_BYTES + 1)
if len(raw) > _MAX_UPLOAD_BYTES:
raise HTTPException(413, f"file too large (max {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
name = file.filename or "upload"
lower = name.lower()
if lower.endswith(".pdf"):
import io
from pypdf import PdfReader
try:
reader = PdfReader(io.BytesIO(raw))
except Exception as e: # noqa: BLE001
raise HTTPException(422, f"Could not read PDF: {e}") from e
# Cap page count: a small but deeply-compressed / many-page PDF can make extract_text
# explode CPU/memory (a decompression bomb) (audit M3).
if len(reader.pages) > _MAX_PDF_PAGES:
raise HTTPException(413, f"PDF has too many pages (max {_MAX_PDF_PAGES})")
try:
text = "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
except Exception as e: # noqa: BLE001
raise HTTPException(422, f"Could not read PDF: {e}") from e
if not text:
raise HTTPException(422, "PDF contains no extractable text (scanned image PDFs need OCR).")
else:
# Strip HTML, parse CSV/JSON into per-record text, and reject binaries with a clear 422.
text = _decode_upload(name, raw)
src = await KnowledgeService.create_source(session, tenant_id, project_id, kind="file", name=name, text=text, folder=folder, chunking_strategy=(chunking_strategy or None))
await safe_snapshot(session, "kb_source", src, author=user, label="added")
# The file bytes are fully read above; only the chunk+embed runs in the background so a
# large upload doesn't block (and time out) the request. Returns "queued"; the UI polls.
await _queue_ingest(session, tenant_id, src)
return src
@router.get("/folders", response_model=list[str])
async def list_folders(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await KnowledgeService.list_folders(session, tenant_id, project_id)
@router.get("/health")
async def embedding_health(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
"""Embedding-dimension health: flags sources embedded with a different model than the
project's current embedder (which would silently return no search results)."""
return await KnowledgeService.embedding_health(session, tenant_id, project_id)
@router.post("/sources/{source_id}/reingest")
async def reingest_source(project_id: str, source_id: str, body: RechunkIn | None = None,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
"""Re-fetch + re-embed a source (re-crawl a site, or re-embed under the current model).
An optional body overrides the chunking (strategy / size / overlap) before re-ingest."""
from sqlalchemy import select
from forge.models import KbSource
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
if src is None:
raise HTTPException(status_code=404, detail="source not found")
if body and (body.chunking_strategy or body.chunk_size is not None or body.chunk_overlap is not None):
KnowledgeService._apply_chunk_overrides(
src, chunking_strategy=body.chunking_strategy,
chunk_size=body.chunk_size, chunk_overlap=body.chunk_overlap,
)
# Mark pending + persist overrides, then re-embed off the request (see add_source). Re-embed /
# re-chunk is NOT snapshotted: it's frequent config churn, not an add/remove worth logging.
src.status = "queued"
await session.commit()
await _queue_ingest(session, tenant_id, src, reingest=True, label="reingest")
return {"id": src.id, "status": src.status, "chunks": src.chunks}
@router.post("/sources/rechunk")
async def rechunk_sources(project_id: str, body: RechunkBulkIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
"""Re-chunk a multi-selected set of sources with one shared set of overrides
(strategy / size / overlap), then re-embed each (in the background). Tenant/project-scoped."""
from sqlalchemy import select
from forge.models import KbSource
if not body.source_ids:
return []
rows = (await session.execute(
select(KbSource).where(
KbSource.tenant_id == tenant_id, KbSource.project_id == project_id,
KbSource.id.in_(body.source_ids),
)
)).scalars().all()
for src in rows:
KnowledgeService._apply_chunk_overrides(
src, chunking_strategy=body.chunking_strategy,
chunk_size=body.chunk_size, chunk_overlap=body.chunk_overlap,
)
src.status = "queued"
await session.commit() # persist overrides + queued status for the whole batch at once
for src in rows:
await _queue_ingest(session, tenant_id, src, reingest=True, label="rechunk")
return [{"id": src.id, "status": src.status, "chunks": src.chunks} for src in rows]
@router.patch("/sources/{source_id}", response_model=KbSourceOut)
async def update_source(project_id: str, source_id: str, body: dict, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
"""Move a source between folders (the only mutable field; content requires re-ingest).
Not snapshotted: a folder move is minor bookkeeping, not an add/remove worth logging."""
from sqlalchemy import select
from forge.models import KbSource
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
if src is None:
raise HTTPException(404, "Source not found")
if "folder" in body:
src.folder = str(body["folder"] or "")
await session.commit()
await session.refresh(src)
return src
@router.delete("/sources/{source_id}", status_code=204)
async def delete_source(project_id: str, source_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
from sqlalchemy import select
from forge.models import KbSource
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
if src is None:
raise HTTPException(404, "Source not found")
# Log the removal (name captured in the snapshot) before the row is gone.
await safe_snapshot(session, "kb_source", src, author=user, label="removed")
await KnowledgeService.delete_source(session, src)
@router.post("/search")
async def search(project_id: str, body: KnowledgeSearchIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
hits = await KnowledgeService.search(session, tenant_id, project_id, body.query, top_k=body.top_k, folders=body.folders, hybrid=body.hybrid, rerank=body.rerank)
return [{"text": h.text, "score": round(h.score, 4), "source_id": h.metadata.get("source_id")} for h in hits]
@router.post("/dedupe")
async def dedupe_chunks(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
"""Remove exact-duplicate chunks (identical text) project-wide, keeping one copy of each.
Returns {removed, groups, sources_affected, remaining}."""
return await KnowledgeService.dedupe_chunks(session, tenant_id, project_id)
@router.post("/map")
async def chunk_map(project_id: str, body: KnowledgeMapIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
"""Chunk-map visualizer: 2-D (PCA) projection of the project's chunk vectors, colored by
source, with an optional query overlay showing what retrieval returns. Read-only."""
return await KnowledgeService.chunk_map(
session, tenant_id, project_id, query=body.query, folders=body.folders,
source_ids=body.source_ids, limit=body.limit, hybrid=body.hybrid,
rerank=body.rerank, top_k=body.top_k,
)
@router.get("/chunk")
async def chunk_detail(project_id: str, chunk_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
"""Full text (+ light metadata) of a single chunk, fetched on demand when a dot is selected
in the chunk map (the map payload carries only a short preview). Scoped to the tenant/project.
404 when the id isn't in this project's current-embedder collection."""
detail = await KnowledgeService.chunk_detail(session, tenant_id, project_id, chunk_id)
if detail is None:
raise HTTPException(404, "Chunk not found.")
return detail
@qa_router.get("", response_model=list[QaPairOut])
async def list_qa(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await KnowledgeService.list_qa(session, tenant_id, project_id)
@qa_router.get("/kinds", response_model=list[str])
async def list_qa_kinds(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await KnowledgeService.list_qa_kinds(session, tenant_id, project_id)
@qa_router.post("", response_model=QaPairOut, status_code=201)
async def add_qa(project_id: str, body: QaPairCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
qa = await KnowledgeService.create_qa(session, tenant_id, project_id, question=body.question, answer=body.answer, kind=body.kind, tags=body.tags)
await safe_snapshot(session, "qa_pair", qa, author=user, label="added")
return qa
@qa_router.patch("/{qa_id}", response_model=QaPairOut)
async def update_qa(project_id: str, qa_id: str, body: QaPairUpdate,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
from sqlalchemy import select
from forge.models import QaPair
qa = (await session.execute(select(QaPair).where(
QaPair.tenant_id == tenant_id, QaPair.project_id == project_id, QaPair.id == qa_id,
))).scalar_one_or_none()
if qa is None:
raise HTTPException(404, "Q&A pair not found")
changes = body.model_dump(exclude_unset=True)
if changes.get("question") is not None and not changes["question"].strip():
raise HTTPException(422, "Question cannot be empty")
qa = await KnowledgeService.update_qa(session, qa, **changes)
await safe_snapshot(session, "qa_pair", qa, author=user, label="changed")
return qa
@qa_router.delete("/{qa_id}", status_code=204)
async def delete_qa(project_id: str, qa_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
from sqlalchemy import select
from forge.models import QaPair
qa = (await session.execute(select(QaPair).where(
QaPair.tenant_id == tenant_id, QaPair.project_id == project_id, QaPair.id == qa_id,
))).scalar_one_or_none()
if qa is None:
raise HTTPException(404, "Q&A pair not found")
await safe_snapshot(session, "qa_pair", qa, author=user, label="removed")
await KnowledgeService.delete_qa(session, qa)
+121
View File
@@ -0,0 +1,121 @@
"""MCP client CRUD - register external MCP servers a project's tools can consume."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.models import McpClient
router = APIRouter(prefix="/v1/projects/{project_id}/mcp-clients", tags=["mcp-clients"])
class McpClientIn(BaseModel):
name: str
transport: str = "streamable_http" # streamable_http | sse | stdio
url: str | None = None
command: str | None = None
args: dict = {}
headers_ref: str | None = None
enabled: bool = True
class McpClientPatch(BaseModel):
name: str | None = None
enabled: bool | None = None
disabled_tools: list | None = None # remote tool names toggled off
url: str | None = None
headers_ref: str | None = None
def _out(m: McpClient) -> dict:
return {"id": m.id, "name": m.name, "transport": m.transport, "url": m.url,
"command": m.command, "args": m.args, "headers_ref": m.headers_ref,
"enabled": m.enabled, "disabled_tools": m.disabled_tools or []}
@router.get("")
async def list_clients(project_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
rows = (await session.execute(
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.project_id == project_id)
)).scalars()
return [_out(m) for m in rows]
@router.post("", status_code=201)
async def create_client(project_id: str, body: McpClientIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
m = McpClient(tenant_id=tenant_id, project_id=project_id, name=body.name, transport=body.transport,
url=body.url, command=body.command, args=body.args, headers_ref=body.headers_ref, enabled=body.enabled)
session.add(m)
await session.commit()
await session.refresh(m)
return _out(m)
@router.patch("/{client_id}")
async def update_client(project_id: str, client_id: str, body: McpClientPatch, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
m = (await session.execute(
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
)).scalar_one_or_none()
if m is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
if body.name is not None:
m.name = body.name
if body.enabled is not None:
m.enabled = body.enabled
if body.disabled_tools is not None:
m.disabled_tools = body.disabled_tools
if body.url is not None:
m.url = body.url
if body.headers_ref is not None:
m.headers_ref = body.headers_ref
await session.commit()
await session.refresh(m)
# Drop the cached connection so running agents pick up the new config (audit F12).
from forge.tools.mcp import invalidate_client
invalidate_client(client_id)
return _out(m)
@router.get("/{client_id}/tools")
async def list_remote_tools(project_id: str, client_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Connect to the server and list the tools it exposes - drives the 'pick which to add' UI."""
from forge.tools.mcp import McpUnavailable, discover_tools
row = (await session.execute(
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
)).scalar_one_or_none()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
try:
tools = await discover_tools(row, tenant_id, project_id)
except McpUnavailable as e:
return {"ok": False, "error": str(e)}
except Exception as e: # noqa: BLE001 - surface connect/auth errors to the UI, don't 500
return {"ok": False, "error": f"Could not connect: {e}"}
return {"ok": True, "tools": tools}
@router.delete("/{client_id}")
async def delete_client(project_id: str, client_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
m = (await session.execute(
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
)).scalar_one_or_none()
if m is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
await session.delete(m)
await session.commit()
from forge.tools.mcp import invalidate_client
invalidate_client(client_id)
return {"ok": True}
+317
View File
@@ -0,0 +1,317 @@
"""OAuth 2.1 authorization + resource server for Forge's MCP endpoint (MCP authorization spec).
Lets ANY standard MCP client (Claude Desktop, Cursor, VS Code) authenticate a user over
`POST /v1/mcp/{project_id}` without a pre-shared key: the client discovers this server, registers
dynamically (RFC 7591), runs an authorization-code + PKCE flow, and presents the resulting
audience-bound access token as `Authorization: Bearer …`. The MCP router validates the token and
acts AS that user (forge.routers.mcp_server._oauth_end_user).
Standards implemented: OAuth 2.1 (authorization code + PKCE S256, public clients), Protected
Resource Metadata (RFC 9728), Authorization Server Metadata (RFC 8414), Dynamic Client
Registration (RFC 7591), and Resource Indicators (RFC 8707 - the token `aud` is the project's
canonical MCP URL, so tokens can't be replayed at a different resource).
GATED: everything here 404s unless `settings.mcp_oauth_enabled` is on, so an operator opts in after
review. KNOWN REVIEW ITEMS (documented, intentionally conservative): the consent screen is a
minimal server-rendered login form; MFA-enabled accounts are refused here (no MFA bypass) and must
use a personal access token; there is no per-client consent memory. Harden these before relying on
it in production.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import html as _html
import secrets as _secrets
import urllib.parse
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy import select
from forge.config import settings
from forge.db.base import SessionLocal
from forge.models import OAuthClient
from forge.security import (
TokenError,
create_mcp_access_token,
create_mcp_authorization_code,
create_mcp_refresh_token,
decode_token,
revoke,
)
from forge.services.auth import AuthError, AuthService
from forge.util.ratelimit import rate_limiter
router = APIRouter(tags=["mcp-oauth"])
def _enabled() -> None:
if not settings.mcp_oauth_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "MCP OAuth is not enabled")
def _base() -> str:
return settings.public_base_url.rstrip("/")
def _valid_redirect(uri: str) -> bool:
"""Only https, or a loopback address for native clients (open-redirect / spec hygiene)."""
try:
p = urllib.parse.urlparse(uri)
except ValueError:
return False
if p.scheme == "https":
return True
return p.scheme in ("http",) and p.hostname in ("localhost", "127.0.0.1", "::1")
def _pkce_ok(verifier: str, challenge: str) -> bool:
digest = hashlib.sha256((verifier or "").encode()).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return hmac.compare_digest(computed, challenge or "")
async def _load_client(client_id: str | None) -> OAuthClient | None:
if not client_id:
return None
async with SessionLocal() as s:
return (await s.execute(select(OAuthClient).where(OAuthClient.client_id == client_id))).scalar_one_or_none()
def _oauth_error(error: str, description: str, status_code: int = 400) -> JSONResponse:
return JSONResponse({"error": error, "error_description": description}, status_code=status_code)
# --- Discovery (RFC 8414 / RFC 9728) --------------------------------------------------------
@router.get("/.well-known/oauth-authorization-server")
async def authorization_server_metadata():
_enabled()
b = _base()
return {
"issuer": b,
"authorization_endpoint": f"{b}/v1/oauth/authorize",
"token_endpoint": f"{b}/v1/oauth/token",
"registration_endpoint": f"{b}/v1/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
}
@router.get("/.well-known/oauth-protected-resource/v1/mcp/{project_id}")
async def protected_resource_metadata(project_id: str):
_enabled()
b = _base()
return {"resource": f"{b}/v1/mcp/{project_id}", "authorization_servers": [b]}
# --- Dynamic client registration (RFC 7591) -------------------------------------------------
@router.post("/v1/oauth/register", status_code=201)
async def register_client(body: dict):
_enabled()
redirect_uris = body.get("redirect_uris") or []
if not isinstance(redirect_uris, list) or not redirect_uris:
return _oauth_error("invalid_client_metadata", "redirect_uris is required")
if not all(isinstance(u, str) and _valid_redirect(u) for u in redirect_uris):
return _oauth_error("invalid_redirect_uri", "redirect_uris must be https or loopback http")
client_id = "mcp_" + _secrets.token_urlsafe(24)
name = str(body.get("client_name"))[:200] if body.get("client_name") else None
async with SessionLocal() as s:
s.add(OAuthClient(client_id=client_id, client_name=name, redirect_uris=redirect_uris))
await s.commit()
return {
"client_id": client_id,
"client_name": name,
"redirect_uris": redirect_uris,
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
}
# --- Authorization endpoint (code + PKCE) ---------------------------------------------------
# Branded consent page - mirrors the console's login card (Forge wordmark, accent-orange
# button, card on a tinted background) so the OAuth login reads as Forge, while staying a
# self-contained server-rendered page (the authorization server can't depend on the SPA).
# Uses %%…%% sentinels (not str.format/f-string) so the CSS braces need no escaping. Every
# injected value is HTML-escaped; HIDDEN/ERR are substituted before NAME so a sentinel-looking
# client name can't be re-substituted (str.replace is single-pass, non-recursive).
_CONSENT_TEMPLATE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Authorize %%NAME%% · Forge</title>
<style>
:root{--bg:#F6F7F9;--card:#FFFFFF;--line:#E2E6EC;--fg:#11161C;--fg2:#7A848F;--accent:#E8541F;--accent-dim:#B8420F;--err:#D23A34;}
@media (prefers-color-scheme:dark){:root{--bg:#0A0C0F;--card:#0F1318;--line:#2A323D;--fg:#EEF2F6;--fg2:#7A848F;--accent:#FF6A3D;--accent-dim:#C24E2B;--err:#F2615B;}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;}
.card{width:380px;max-width:calc(100vw - 32px);background:var(--card);border:1px solid var(--line);border-radius:14px;padding:28px;box-shadow:0 12px 40px rgba(17,22,28,.12);}
.brand{font-size:22px;font-weight:700;letter-spacing:-.01em;margin:0 0 4px;}
.sub{color:var(--fg2);font-size:14px;line-height:1.5;margin:0 0 20px;}
.sub b{color:var(--fg);font-weight:600;}
label{display:block;margin-bottom:12px;}
.lbl{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--fg2);margin-bottom:6px;}
.lbl .hint{text-transform:none;letter-spacing:0;font-weight:400;}
input{width:100%;height:38px;padding:0 12px;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:8px;outline:none;}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,84,31,.15);}
button{width:100%;height:40px;margin-top:6px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:14px;font-weight:600;cursor:pointer;}
button:hover{background:var(--accent-dim);}
.err{color:var(--err);font-size:13px;margin-bottom:12px;}
.fieldhint{color:var(--fg2);font-size:12px;line-height:1.45;margin:-6px 0 14px;}
</style></head>
<body>
<form class="card" method="post" action="/v1/oauth/authorize">
<div class="brand">Forge</div>
<div class="sub"><b>%%NAME%%</b> wants to access your Forge tools over MCP, acting as you.</div>
%%ERR%%
%%HIDDEN%%
<label><span class="lbl">Email</span><input name="email" type="email" required autocomplete="username"></label>
<label><span class="lbl">Password</span><input name="password" type="password" required autocomplete="current-password"></label>
<label><span class="lbl">Workspace id <span class="hint">(optional)</span></span><input name="workspace_id"></label>
<div class="fieldhint">Leave blank unless the same email is registered in multiple workspaces. It's your workspace ID (Settings &gt; General), not the project ID.</div>
<button type="submit">Authorize</button>
</form>
</body></html>"""
def _consent_html(fields: dict, client: OAuthClient, error: str | None = None) -> str:
def h(v: object) -> str:
return _html.escape(str(v or ""))
hidden = "".join(
f'<input type="hidden" name="{h(k)}" value="{h(v)}">'
for k, v in fields.items()
)
err = f'<div class="err">{h(error)}</div>' if error else ""
name = h(client.client_name or client.client_id)
return (
_CONSENT_TEMPLATE
.replace("%%HIDDEN%%", hidden)
.replace("%%ERR%%", err)
.replace("%%NAME%%", name)
)
def _authorize_fields(q: dict) -> dict:
return {
"client_id": q.get("client_id", ""),
"redirect_uri": q.get("redirect_uri", ""),
"code_challenge": q.get("code_challenge", ""),
"state": q.get("state", ""),
"resource": q.get("resource", ""),
"scope": q.get("scope", ""),
}
@router.get("/v1/oauth/authorize", response_class=HTMLResponse)
async def authorize_form(request: Request):
_enabled()
q = dict(request.query_params)
if q.get("response_type") != "code":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "response_type must be 'code'")
if q.get("code_challenge_method") != "S256" or not q.get("code_challenge"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "PKCE with code_challenge_method=S256 is required")
client = await _load_client(q.get("client_id"))
if client is None or q.get("redirect_uri") not in (client.redirect_uris or []):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown client_id or unregistered redirect_uri")
return HTMLResponse(_consent_html(_authorize_fields(q), client))
@router.post("/v1/oauth/authorize")
async def authorize_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
workspace_id: str = Form(""),
client_id: str = Form(...),
redirect_uri: str = Form(...),
code_challenge: str = Form(...),
state: str = Form(""),
resource: str = Form(""),
scope: str = Form(""),
):
_enabled()
fields = {"client_id": client_id, "redirect_uri": redirect_uri, "code_challenge": code_challenge,
"state": state, "resource": resource, "scope": scope}
client = await _load_client(client_id)
if client is None or redirect_uri not in (client.redirect_uris or []):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown client_id or unregistered redirect_uri")
# Throttle the credential form per client IP (brute-force guard on this login surface).
ip = request.client.host if request.client else "?"
if not rate_limiter.allow(f"oauth_authorize:{ip}", rate=20, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts, slow down")
async with SessionLocal() as s:
try:
user = await AuthService.authenticate(s, email=email, password=password, tenant_id=workspace_id or None)
except AuthError as e:
return HTMLResponse(_consent_html(fields, client, error=str(e)), status_code=401)
# No MFA bypass: an account with TOTP enabled must not authorize via this minimal form.
if await AuthService.totp_status(s, user.id):
return HTMLResponse(
_consent_html(fields, client, error="This account uses MFA; use a personal access token for MCP instead."),
status_code=401,
)
code = create_mcp_authorization_code(claims={
"sub": user.id, "tid": user.tenant_id, "role": user.role,
"cid": client_id, "ru": redirect_uri, "cc": code_challenge, "res": resource or "",
})
sep = "&" if "?" in redirect_uri else "?"
url = f"{redirect_uri}{sep}code={urllib.parse.quote(code)}"
if state:
url += f"&state={urllib.parse.quote(state)}"
return RedirectResponse(url, status_code=status.HTTP_302_FOUND)
# --- Token endpoint -------------------------------------------------------------------------
@router.post("/v1/oauth/token")
async def token(
grant_type: str = Form(...),
code: str = Form(""),
redirect_uri: str = Form(""),
client_id: str = Form(""),
code_verifier: str = Form(""),
refresh_token: str = Form(""),
):
_enabled()
if grant_type == "authorization_code":
try:
claims = decode_token(code, expected_type="mcp_auth_code")
except TokenError:
return _oauth_error("invalid_grant", "invalid or expired authorization code")
if claims.get("cid") != client_id or claims.get("ru") != redirect_uri:
return _oauth_error("invalid_grant", "client_id / redirect_uri mismatch")
if not _pkce_ok(code_verifier, claims.get("cc", "")):
return _oauth_error("invalid_grant", "PKCE verification failed")
# Single-use: burn the code's jti so a replayed authorization code is rejected (decode_token
# checks the revocation denylist). `exp` lets the entry self-prune.
revoke(claims.get("jti"), exp=claims.get("exp"))
resource = claims.get("res") or ""
access = create_mcp_access_token(claims={
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
"res": resource, "cid": client_id,
})
refresh = create_mcp_refresh_token(claims={
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
"cid": client_id, "res": resource,
})
return {"access_token": access, "token_type": "Bearer", "expires_in": 3600, "refresh_token": refresh, "scope": ""}
if grant_type == "refresh_token":
try:
claims = decode_token(refresh_token, expected_type="mcp_refresh")
except TokenError:
return _oauth_error("invalid_grant", "invalid refresh_token")
if client_id and claims.get("cid") != client_id:
return _oauth_error("invalid_grant", "client mismatch")
resource = claims.get("res") or ""
access = create_mcp_access_token(claims={
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
"res": resource, "cid": claims.get("cid"),
})
return {"access_token": access, "token_type": "Bearer", "expires_in": 3600, "scope": ""}
return _oauth_error("unsupported_grant_type", f"unsupported grant_type {grant_type!r}")
+512
View File
@@ -0,0 +1,512 @@
"""Expose a project's tools as an MCP server, over two transports that share one core.
- **Streamable HTTP** (MCP spec 2025-03-26+): the transport native clients (Claude Desktop,
Cursor, VS Code) speak directly, so they connect WITHOUT an `mcp-remote` proxy bridge. A POST
is answered with `application/json` or, when the client's `Accept` allows it, a
`text/event-stream` (SSE) reply; GET/DELETE are handled by the SDK transport. Backed by the
official `mcp` SDK's StreamableHTTPServerTransport in STATELESS mode — a fresh transport per
request, no server-side session state — which is exactly Forge's model: every request is
authenticated on its own and the tool surface is resolved per project + per acting identity.
- **Legacy JSON-RPC** (request/response over HTTP POST): the original hand-rolled path, kept for
simple HTTP clients and internal callers that POST plain JSON without the streamable headers.
The transport is chosen per request: a POST whose `Accept` includes `text/event-stream`, or any
GET/DELETE, is served over Streamable HTTP; a plain-JSON POST uses the legacy path. Both resolve the
SAME surface (`_resolve`) and run the SAME dispatch (`_dispatch`), so behavior and tracing are
identical whichever transport a client uses. Auth is a per-project API key stored in
`project.config.mcp_api_key` (required when set; open in the no-auth dev default).
Tool sets ("toolsets", GitHub-MCP style): a project's tools can be published as named groups.
- Base endpoint /v1/mcp/{project_id} exposes the project's published surface
(project.config.mcp_published_toolsets: a list of set slugs, or "all"/"default"; unset =>
every enabled tool, the prior behavior).
- Per-set endpoint /v1/mcp/{project_id}/toolset/{slug} exposes ONLY that set's tools, so
a client can add one MCP server per toolset. The optional name allow-list
(project.config.mcp_exposed_tools) still applies as a further filter.
Project tools (base endpoint only, each gated by a project.config flag, published ALONGSIDE the
toolset tools and independent of the toolset allow-list): the whole configured workflow as one
tool (mcp_expose_workflow -> run_workflow), knowledge-base document search (mcp_expose_knowledge
-> search_knowledge_base) and curated Q&A lookup (mcp_expose_faq -> lookup_faq). The two knowledge
tools reuse the SAME builder the agent nodes use, so their behavior and tracing match exactly.
"""
from __future__ import annotations
import hmac
import logging
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, HTTPException, Request, status
from sqlalchemy import select
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from forge.config import settings
from forge.db.base import SessionLocal
from forge.deps import run_context as parse_run_context
from forge.models import Project, User
from forge.security import TokenError, decode_token
from forge.services.apikeys import ApiKeyService, looks_like_pat
from forge.services.runtime import build_compile_context
from forge.services.tool_sets import ToolSetService
from forge.util.ratelimit import rate_limiter
# Streamable-HTTP transport comes from the optional `mcp` extra. Import it lazily-at-module-load
# behind a guard so this router still registers (legacy JSON-RPC keeps working) when the extra is
# not installed; the streamable branch then returns a clear 501. anyio is a core dependency.
try:
import anyio
from mcp import types as mcp_types
from mcp.server.lowlevel import Server as LowLevelMCPServer
from mcp.server.streamable_http import StreamableHTTPServerTransport
from mcp.server.transport_security import TransportSecuritySettings
# Forge already enforces trusted-hosts + https public URLs at the app layer in prod, so the
# transport's own DNS-rebinding Host/Origin check (default ON, empty allow-lists => blocks all)
# is redundant here and would reject every request. Disable it and rely on the app-layer guard.
_MCP_SECURITY = TransportSecuritySettings(enable_dns_rebinding_protection=False)
_STREAMABLE_OK = True
except Exception: # pragma: no cover - exercised only when the `mcp` extra is absent
_STREAMABLE_OK = False
log = logging.getLogger("forge.routers.mcp_server")
router = APIRouter(prefix="/v1/mcp", tags=["mcp-server"])
def _workflow_tool_name(cfg: dict) -> str | None:
"""The MCP tool name that runs the project's configured workflow, if exposure is enabled
(project.config.mcp_expose_workflow). Lets an external MCP client invoke a whole Forge
workflow as a single tool, not just the project's individual tools."""
if not cfg.get("mcp_expose_workflow"):
return None
return str(cfg.get("mcp_workflow_tool_name") or "run_workflow")
def _capability_tools(cfg: dict, ctx, toolset_slug: str | None) -> list:
"""Project-level knowledge tools exposed over MCP, gated by project.config flags (mirrors
`_workflow_tool_name`): `mcp_expose_knowledge` -> `search_knowledge_base` (RAG over the
project's documents) and `mcp_expose_faq` -> `lookup_faq` (curated Q&A). Reuses the SAME
builder the agent nodes use (`build_knowledge_capability_tools`), so behavior + tracing match.
Base endpoint only: like the workflow tool these are a whole-project surface, not part of any
one toolset, so a per-set (toolset) endpoint never carries them.
"""
if toolset_slug:
return []
kn: dict = {}
if cfg.get("mcp_expose_knowledge"):
kn["rag"] = {"enabled": True}
if cfg.get("mcp_expose_faq"):
kn["qa"] = {"enabled": True}
if not kn:
return []
from forge.tools.builtin import build_knowledge_capability_tools
return build_knowledge_capability_tools(kn, ctx)
def _tool_input_schema(tool) -> dict:
"""The JSON Schema for a StructuredTool's arguments, degrading to an open object on error."""
try:
return tool.args_schema.model_json_schema() if tool.args_schema else {"type": "object"}
except Exception: # noqa: BLE001
return {"type": "object"}
def _exposed_names(ctx, sets: list, toolset_slug: str | None, excluded_ids: set[str]) -> set[str]:
"""The flat set of tool NAMES to expose over MCP.
MCP has no "toolset" primitive - `tools/list` is a flat array - so tool sets are purely a
Forge-side grouping we flatten here. The surface is the enabled tools of EXPOSED sets, MINUS
any individually excluded tools: everything is published by default and the operator unticks
what they don't want (project.config.mcp_excluded_tools = tool ids). `ctx.tool_specs` holds only
ENABLED tools, so disabled tools drop out automatically. No loose/direct tools: a tool that
isn't in an exposed set is not published. `toolset_slug` scopes to that one set if exposed.
"""
if toolset_slug:
chosen = [x for x in sets if x.slug == toolset_slug and x.exposed]
else:
chosen = [x for x in sets if x.exposed]
names: set[str] = set()
for st in chosen:
for tid in ctx.toolset_members.get(st.id, []):
if tid in excluded_ids:
continue
spec = ctx.tool_specs.get(tid)
if spec is not None:
names.add(spec["tool"].name)
return names
def _bearer(request: Request) -> str | None:
auth = request.headers.get("authorization") or ""
parts = auth.split(None, 1)
return parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else None
async def _load_project(project_id: str) -> Project:
async with SessionLocal() as s:
proj = (await s.execute(select(Project).where(Project.id == project_id))).scalar_one_or_none()
if proj is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
return proj
def _session_end_user(token: str | None, proj: Project) -> dict | None:
"""The verified end_user carried by a Forge session token scoped to THIS project, or None.
Lets an MCP client authenticate as a specific end user (create_session_token) instead of the
shared project key, so entitlement gating and {{ctx.*}} injection act on their behalf."""
if not token:
return None
try:
claims = decode_token(token, expected_type="session")
except TokenError:
return None
if claims.get("pid") != proj.id or claims.get("tid") != proj.tenant_id:
return None
eu = claims.get("end_user")
return eu if isinstance(eu, dict) else None
async def _pat_end_user(token: str | None, proj: Project) -> dict | None:
"""The end_user for a per-user Personal Access Token (forge_pat_) presented to a project's MCP
server, or None. Lets an individual authenticate any MCP client with a pasteable token; the
acting identity is the token's owning user, scoped to the token's tenant (+ project if set)."""
if not token or not looks_like_pat(token):
return None
async with SessionLocal() as s:
key = await ApiKeyService.resolve_personal(s, token)
if key is None or key.tenant_id != proj.tenant_id:
return None
if key.project_id and key.project_id != proj.id:
return None
user = (await s.execute(select(User).where(User.id == key.user_id))).scalar_one_or_none()
if user is None or user.status != "active":
return None
return {"id": user.id, "email": user.email, "display_name": user.email, "roles": [user.role]}
async def _oauth_end_user(token: str | None, proj: Project) -> dict | None:
"""The end_user for a Forge-issued OAuth 2.1 MCP access token (see forge.routers.mcp_oauth),
or None. Validates the token's audience is THIS project's canonical MCP URL (RFC 8707) so a
token minted for another resource can't be replayed here. Only active when MCP OAuth is on."""
if not settings.mcp_oauth_enabled or not token:
return None
try:
claims = decode_token(token, expected_type="mcp_access")
except TokenError:
return None
canonical = f"{settings.public_base_url.rstrip('/')}/v1/mcp/{proj.id}"
res = claims.get("res") or "" # resource binding (RFC 8707); a custom claim so the shared
if res != canonical and not res.startswith(canonical): # JWT decoder doesn't reject on `aud`
return None
async with SessionLocal() as s:
user = (await s.execute(select(User).where(User.id == claims.get("sub")))).scalar_one_or_none()
if user is None or user.status != "active" or user.tenant_id != proj.tenant_id:
return None
return {"id": user.id, "email": user.email, "display_name": user.email, "roles": [user.role]}
def _rpc(rid, result=None, error=None):
out = {"jsonrpc": "2.0", "id": rid}
if error is not None:
out["error"] = error
else:
out["result"] = result
return out
async def _authorize(request: Request, proj: Project, cfg: dict) -> dict | None:
"""Authorize the caller and resolve the acting end user (identity), raising 401 on failure.
Shared by both transports. Two accepted credential kinds:
- the shared per-project mcp_api_key -> authorized, NO per-user identity (server-to-server);
- a per-user credential (project-scoped session token, forge_pat_ PAT, or OAuth 2.1 access
token) -> authorized AS that user, so entitlement gating + {{ctx.*}} act on their behalf.
Portable "use anywhere" identity: any MCP client just sends Authorization: Bearer <token>."""
bearer = _bearer(request)
api_key = cfg.get("mcp_api_key")
if api_key and hmac.compare_digest(bearer or "", api_key):
return None # shared-key mode (constant-time compare; matches deps.py)
end_user = (
_session_end_user(bearer, proj)
or await _pat_end_user(bearer, proj)
or await _oauth_end_user(bearer, proj)
)
if end_user is None and (api_key or settings.auth_required or settings.mcp_oauth_enabled):
detail = "invalid MCP credential" if api_key else "authentication required to use this MCP server"
headers = None
if settings.mcp_oauth_enabled:
# RFC 9728: point the client at this resource's metadata so it can start OAuth.
prm = f"{settings.public_base_url.rstrip('/')}/.well-known/oauth-protected-resource/v1/mcp/{proj.id}"
headers = {"WWW-Authenticate": f'Bearer resource_metadata="{prm}"'}
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers=headers)
return end_user
# --- tool surface + dispatch (shared by both transports) -------------------------------------
@dataclass
class _Surface:
"""The resolved MCP surface for one request: the compile context, the flat allow-list of
exposed tool names, the optional workflow tool name, the project-level knowledge tools (by
name), and the toolset scope. Built once per request by `_resolve`."""
ctx: Any
allow: set[str]
wf_tool: str | None
cap_tools: dict
toolset_slug: str | None
async def _resolve(proj: Project, cfg: dict, toolset_slug: str | None, end_user: dict | None, rc) -> _Surface:
"""Resolve the project's exposed tool surface for the acting identity + run context."""
async with SessionLocal() as s:
ctx = await build_compile_context(
s, tenant_id=proj.tenant_id, project_id=proj.id, end_user=end_user, run_context=rc,
)
sets = await ToolSetService.list(s, proj.tenant_id, proj.id)
excluded_ids = {str(x) for x in (cfg.get("mcp_excluded_tools") or [])}
allow = _exposed_names(ctx, sets, toolset_slug, excluded_ids)
cap_tools = {t.name: t for t in _capability_tools(cfg, ctx, toolset_slug)}
return _Surface(ctx=ctx, allow=allow, wf_tool=_workflow_tool_name(cfg), cap_tools=cap_tools, toolset_slug=toolset_slug)
def _list_items(surface: _Surface) -> list[dict]:
"""The `tools/list` array: enabled tools of exposed sets, plus the base-endpoint-only
workflow + knowledge tools. Same shape for both transports."""
items: list[dict] = []
for spec in surface.ctx.tool_specs.values():
tool = spec["tool"]
if tool.name not in surface.allow:
continue # only enabled tools of exposed tool sets are published
items.append({"name": tool.name, "description": tool.description or "", "inputSchema": _tool_input_schema(tool)})
# The whole-workflow-as-one-tool and the knowledge tools are a project-level surface: base
# endpoint only, never a per-set (toolset) endpoint.
if surface.wf_tool and not surface.toolset_slug:
items.append({
"name": surface.wf_tool,
"description": "Run this project's configured workflow with a text message and return its reply.",
"inputSchema": {
"type": "object",
"properties": {"message": {"type": "string", "description": "The user message / input to run the workflow with."}},
"required": ["message"],
},
})
for t in surface.cap_tools.values():
items.append({"name": t.name, "description": t.description or "", "inputSchema": _tool_input_schema(t)})
return items
@dataclass
class _CallResult:
"""Outcome of a tools/call. `unknown` marks a tool that is not exposed / not found: the legacy
transport renders it as a JSON-RPC -32602 error, while Streamable HTTP (per the MCP spec, which
reserves protocol errors for malformed requests) renders it as a tool result with isError."""
text: str
is_error: bool
unknown: bool = False
async def _dispatch(request: Request, proj: Project, cfg: dict, surface: _Surface, rc, end_user: dict | None, name: str, args: dict) -> _CallResult:
"""Run one tools/call against the resolved surface. Shared by both transports."""
# Workflow-as-MCP: run the project's configured workflow to completion and return its answer.
if surface.wf_tool and not surface.toolset_slug and name == surface.wf_tool:
from forge.routers.project_run import _configured_workflow
from forge.services.runs import RunService
run_service = RunService(
checkpointer=getattr(request.app.state, "checkpointer", None),
store=getattr(request.app.state, "store", None),
)
message = args.get("message") or args.get("input") or ""
try:
async with SessionLocal() as s:
wf = await _configured_workflow(s, proj.tenant_id, proj.id)
run = await run_service.create_run(
s, tenant_id=proj.tenant_id, project_id=proj.id, workflow_id=wf.id,
input={"messages": [{"role": "user", "content": str(message)}]}, source="mcp",
end_user=end_user,
)
result = await run_service.run_to_completion(
run_id=run.id, tenant_id=proj.tenant_id, project_id=proj.id, run_context=rc,
)
return _CallResult(text=result.get("answer") or "", is_error=result.get("status") == "error")
except Exception: # noqa: BLE001
# Don't leak internal error/stack detail to the external MCP client; log it server-side.
log.exception("mcp workflow run failed (project=%s)", proj.id)
return _CallResult(text="error: workflow run failed", is_error=True)
# Project-level knowledge tools bypass the toolset allow-list (they're a project surface,
# not a toolset member), so dispatch them before the allow check.
cap = surface.cap_tools.get(name)
if cap is not None:
try:
result = await cap.ainvoke(args)
return _CallResult(text=str(result), is_error=False)
except Exception: # noqa: BLE001
log.exception("mcp knowledge tool failed (project=%s, tool=%s)", proj.id, name)
return _CallResult(text="error: tool invocation failed", is_error=True)
if name not in surface.allow:
return _CallResult(text=f"tool {name!r} is not exposed", is_error=True, unknown=True)
tool = next((sp["tool"] for sp in surface.ctx.tool_specs.values() if sp["tool"].name == name), None)
if tool is None:
return _CallResult(text=f"unknown tool {name!r}", is_error=True, unknown=True)
try:
result = await tool.ainvoke(args)
return _CallResult(text=str(result), is_error=False)
except Exception: # noqa: BLE001
# Don't leak internal error/stack detail to the external MCP client; log it server-side.
log.exception("mcp tool invocation failed (project=%s, tool=%s)", proj.id, name)
return _CallResult(text="error: tool invocation failed", is_error=True)
# --- Streamable HTTP transport (official mcp SDK, stateless) ---------------------------------
class _ASGIResponse(Response):
"""A FastAPI-returnable Response that hands the raw ASGI channels to an MCP transport. FastAPI
passes any `Response` instance straight through to Starlette, which then calls it as an ASGI
app - letting the transport own the reply (single JSON body or a streamed text/event-stream)."""
def __init__(self, handler):
self._handler = handler # async (scope, receive, send) -> None
self.background = None # FastAPI reads this on any returned Response before dispatch
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self._handler(scope, receive, send)
def _forge_lowlevel_server(request: Request, proj: Project, cfg: dict, *, toolset_slug: str | None, end_user: dict | None, rc):
"""A per-request MCP `Server` whose tools/list + tools/call resolve Forge's surface lazily
(so `initialize` stays cheap and the surface reflects the caller's identity). One server per
request keeps the acting identity + run context baked in - no shared/global server state."""
name = f"forge-{proj.slug or proj.id}" + (f"-{toolset_slug}" if toolset_slug else "")
server = LowLevelMCPServer(name=name, version="1.0.0")
@server.list_tools()
async def _lt() -> list:
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
return [
mcp_types.Tool(name=i["name"], description=i["description"], inputSchema=i["inputSchema"])
for i in _list_items(surface)
]
@server.call_tool(validate_input=False)
async def _ct(tool_name: str, arguments: dict):
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
res = await _dispatch(request, proj, cfg, surface, rc, end_user, tool_name, arguments)
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=res.text)],
isError=res.is_error or res.unknown,
)
return server
async def _run_streamable(request: Request, proj: Project, server) -> _ASGIResponse:
"""Serve one request over a fresh stateless Streamable-HTTP transport. Mirrors the SDK's own
stateless path: connect the transport, run the MCP server over its in-memory streams in a task,
hand the HTTP request to the transport (which replies JSON or SSE), then tear the transport down."""
transport = StreamableHTTPServerTransport(
mcp_session_id=None, # stateless: no session, no cross-request state
is_json_response_enabled=False, # let the client's Accept decide JSON vs SSE
event_store=None, # no resumability in stateless mode
security_settings=_MCP_SECURITY,
)
async def _drive(scope: Scope, receive: Receive, send: Send) -> None:
async def _serve(*, task_status=anyio.TASK_STATUS_IGNORED):
async with transport.connect() as (read_stream, write_stream):
task_status.started()
try:
await server.run(read_stream, write_stream, server.create_initialization_options(), stateless=True)
except Exception: # noqa: BLE001 # pragma: no cover - server-side crash, logged only
log.exception("mcp streamable session crashed (project=%s)", proj.id)
async with anyio.create_task_group() as tg:
await tg.start(_serve)
await transport.handle_request(scope, receive, send)
await transport.terminate()
return _ASGIResponse(_drive)
# --- routes -----------------------------------------------------------------------------------
@router.api_route("/{project_id}", methods=["GET", "POST", "DELETE"])
async def mcp_rpc(project_id: str, request: Request):
"""Base MCP endpoint: the project's published toolset surface (see module docstring)."""
return await _handle(project_id, request, toolset_slug=None)
@router.api_route("/{project_id}/toolset/{slug}", methods=["GET", "POST", "DELETE"])
async def mcp_rpc_toolset(project_id: str, slug: str, request: Request):
"""Scoped MCP endpoint: exposes only the tools in tool set `slug` (GitHub-style toolset)."""
return await _handle(project_id, request, toolset_slug=slug)
def _wants_stream(request: Request) -> bool:
"""A request routes to Streamable HTTP if it's a GET/DELETE (transport-level methods) or a POST
that accepts an SSE reply; a plain-JSON POST stays on the legacy request/response path."""
if request.method in ("GET", "DELETE"):
return True
return "text/event-stream" in (request.headers.get("accept") or "").lower()
async def _handle(project_id: str, request: Request, *, toolset_slug: str | None):
proj = await _load_project(project_id)
cfg = proj.config or {}
end_user = await _authorize(request, proj, cfg)
# Rate-limit the exposed MCP surface per project (a single static key is shared by every
# caller, so this is the real abuse ceiling). Uses the general API per-minute budget.
if not rate_limiter.allow(f"mcp:{project_id}", rate=settings.api_rate_limit_per_minute, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "MCP rate limit exceeded")
# Ephemeral per-run context (X-Forge-Context): injected into tools as {{ctx.*}} for on-behalf-of
# calls (e.g. the end user's downstream session); never persisted or prompted. Header-only, so
# it is safe to read before the streamable transport consumes the request body.
rc = parse_run_context(request)
# Streamable HTTP (native MCP clients: Claude Desktop / Cursor / VS Code).
if _wants_stream(request):
if not _STREAMABLE_OK:
raise HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"Streamable HTTP transport requires the 'mcp' extra (pip install -e '.[mcp]').",
)
server = _forge_lowlevel_server(request, proj, cfg, toolset_slug=toolset_slug, end_user=end_user, rc=rc)
return await _run_streamable(request, proj, server)
# Legacy request/response JSON-RPC over HTTP POST.
body = await request.json()
rid = body.get("id")
method = body.get("method")
params = body.get("params") or {}
if method == "initialize":
name = f"forge-{proj.slug or project_id}"
if toolset_slug:
name = f"{name}-{toolset_slug}"
return _rpc(rid, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": name, "version": "1.0.0"},
})
if method in ("tools/list", "tools/call"):
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
if method == "tools/list":
return _rpc(rid, {"tools": _list_items(surface)})
# tools/call
name = params.get("name")
args = params.get("arguments") or {}
res = await _dispatch(request, proj, cfg, surface, rc, end_user, name, args)
if res.unknown:
return _rpc(rid, error={"code": -32602, "message": res.text})
return _rpc(rid, {"content": [{"type": "text", "text": res.text}], "isError": res.is_error})
return _rpc(rid, error={"code": -32601, "message": f"method not found: {method}"})
+60
View File
@@ -0,0 +1,60 @@
"""Personal access tokens for a project's MCP server.
A PAT is a per-user, pasteable bearer token (`forge_pat_…`) that authenticates an individual over
`POST /v1/mcp/{project_id}` AS their end_user - the portable "use anywhere" identity for generic MCP
clients (Claude Desktop, Cursor, VS Code). Bound to the current user and scoped to this project; it
is deliberately NOT a general-API credential.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, get_current_user, get_session
from forge.models.entities import ApiKey
from forge.schemas.dto import McpTokenCreate, McpTokenOut
from forge.services.apikeys import ApiKeyService
router = APIRouter(prefix="/v1/projects/{project_id}/mcp-tokens", tags=["mcp-tokens"])
def _out(k: ApiKey, *, token: str | None = None) -> McpTokenOut:
return McpTokenOut(
id=k.id, name=k.name, prefix=k.prefix, project_id=k.project_id, status=k.status,
created_at=k.created_at, last_used_at=k.last_used_at, expires_at=k.expires_at, token=token,
)
def _require_real_user(user: CurrentUser) -> None:
# A PAT must bind a real end user; service / api-key / dev-fallback principals have no user id.
if user.is_fallback or str(user.id).startswith(("apikey:", "service")):
raise HTTPException(status.HTTP_403_FORBIDDEN, "personal tokens require a logged-in user")
@router.get("", response_model=list[McpTokenOut])
async def list_mcp_tokens(project_id: str, session: AsyncSession = Depends(get_session),
user: CurrentUser = Depends(get_current_user)):
_require_real_user(user)
rows = await ApiKeyService.list_personal(session, user.tenant_id, user.id)
# Show this project's tokens plus any tenant-wide (unscoped) personal tokens.
return [_out(k) for k in rows if k.project_id in (None, project_id)]
@router.post("", response_model=McpTokenOut, status_code=201)
async def create_mcp_token(project_id: str, body: McpTokenCreate, session: AsyncSession = Depends(get_session),
user: CurrentUser = Depends(get_current_user)):
_require_real_user(user)
key, plaintext = await ApiKeyService.create_personal(
session, tenant_id=user.tenant_id, user_id=user.id,
name=body.name or "MCP token", project_id=project_id, ttl_days=body.ttl_days,
)
return _out(key, token=plaintext)
@router.delete("/{token_id}", status_code=204)
async def revoke_mcp_token(project_id: str, token_id: str, session: AsyncSession = Depends(get_session),
user: CurrentUser = Depends(get_current_user)):
_require_real_user(user)
if not await ApiKeyService.revoke_personal(session, tenant_id=user.tenant_id, user_id=user.id, key_id=token_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "token not found")
+64
View File
@@ -0,0 +1,64 @@
"""Model catalog - drives every model picker in the console (chat, embedding, reranker).
Served from `forge.model_catalog`, the same lists the built-in pricing rates derive from, so a
dropdown can only ever offer models the backend can actually run (and, for chat, price). This is
the single source of truth: the frontend hardcodes no model lists.
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from forge.model_catalog import CHAT_MODELS, EMBEDDING_MODELS, RERANKER_MODELS
router = APIRouter(prefix="/v1/models", tags=["catalog"])
class ChatModelOut(BaseModel):
id: str
name: str
provider: str
ctx: str
tools: bool
vision: bool
class EmbeddingModelOut(BaseModel):
id: str
name: str
provider: str
dim: int
billed: bool
default: bool
class RerankerModelOut(BaseModel):
id: str
name: str
note: str
default: bool
class ModelCatalogOut(BaseModel):
chat: list[ChatModelOut]
embedding: list[EmbeddingModelOut]
reranker: list[RerankerModelOut]
@router.get("", response_model=ModelCatalogOut)
async def list_models() -> ModelCatalogOut:
return ModelCatalogOut(
chat=[
ChatModelOut(id=m.id, name=m.name, provider=m.provider, ctx=m.ctx, tools=m.tools, vision=m.vision)
for m in CHAT_MODELS
],
embedding=[
EmbeddingModelOut(id=m.id, name=m.name, provider=m.provider, dim=m.dim, billed=m.billed, default=m.default)
for m in EMBEDDING_MODELS
],
reranker=[
RerankerModelOut(id=m.id, name=m.name, note=m.note, default=m.default)
for m in RERANKER_MODELS
],
)
+38
View File
@@ -0,0 +1,38 @@
"""Node-type catalog - drives the canvas palette + validation (from the registry)."""
from __future__ import annotations
from fastapi import APIRouter
import forge.nodes # noqa: F401 (ensure registration)
from forge.engine.registry import all_specs
from forge.schemas.dto import NodeTypeOut, PortOut
router = APIRouter(prefix="/v1/node-types", tags=["catalog"])
def _ports(ports) -> list[PortOut]:
return [
PortOut(
id=p.id, io_type=p.io_type, direction=p.direction,
label=p.label, required=p.required, many=p.many,
)
for p in ports
]
@router.get("", response_model=list[NodeTypeOut])
async def list_node_types() -> list[NodeTypeOut]:
return [
NodeTypeOut(
type=s.type,
category=s.category,
label=s.label or s.type,
description=s.description,
schema_id=s.schema_id,
allows_cycle=s.allows_cycle,
input_ports=_ports(s.input_ports),
output_ports=_ports(s.output_ports),
)
for s in all_specs()
]
+191
View File
@@ -0,0 +1,191 @@
"""3-legged OAuth (authorization_code) connect flow for auth providers.
Flow: the console calls `/oauth/start` to get the provider's authorize URL (carrying a
short-lived signed `state`); the user grants access and the provider redirects the
browser to `/v1/oauth/callback`, which validates `state`, exchanges the code for tokens,
and stores them as a secret. The AuthResolver then auto-refreshes on expiry.
"""
from __future__ import annotations
import base64
import hashlib
import secrets as _secrets
from html import escape
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.auth_providers.resolver import AuthResolver
from forge.config import settings
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.models import AuthProvider
from forge.secrets.store import SecretNotFound, SecretStore
from forge.security import TokenError, create_state_token, decode_token
from forge.util.http import shared_async_client
from forge.util.ssrf import guarded_request
router = APIRouter(tags=["oauth"])
_PREFIX = "/v1/projects/{project_id}/auth-providers/{ap_id}/oauth"
class OAuthStartIn(BaseModel):
# Per-user connect (finding i): the end-user context whose token this connect establishes.
# Only the dims named in the provider's `per_user_context_keys` are carried through the
# (signed) state to the callback, which then stores the bundle under the SAME per-user
# secret name that resolve/refresh read. Omit for a shared, single-account provider.
context: dict | None = None
def _redirect_uri(cfg: dict) -> str:
return cfg.get("redirect_uri") or f"{settings.public_base_url.rstrip('/')}/v1/oauth/callback"
async def _load(session, tenant_id: str, project_id: str, ap_id: str) -> AuthProvider:
ap = (
await session.execute(
select(AuthProvider).where(
AuthProvider.tenant_id == tenant_id, AuthProvider.project_id == project_id, AuthProvider.id == ap_id
)
)
).scalar_one_or_none()
if ap is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "auth provider not found")
if ap.kind != "oauth2_authorization_code":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "provider is not an oauth2_authorization_code provider")
return ap
@router.post(_PREFIX + "/start")
async def oauth_start(
project_id: str, ap_id: str,
body: OAuthStartIn | None = None,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor")),
):
ap = await _load(session, tenant_id, project_id, ap_id)
cfg = ap.config or {}
client_id = await SecretStore().read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) if cfg.get("client_id_ref") else None
if not client_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "client_id secret not configured")
# PKCE (finding i): bind the authorization code to a per-request verifier so an intercepted
# code can't be redeemed without it. The verifier rides in the SIGNED state (tamper-proof)
# and is echoed back to us in the callback. NOTE: the signed state is readable by the
# browser; for a PUBLIC client (no client_secret) store the verifier server-side instead.
verifier = _secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
state_claims = {"tid": tenant_id, "pid": project_id, "ap": ap_id, "cv": verifier}
# Per-user connect: carry ONLY the dims that key this provider's per-user bundle, so the
# callback stores the token under the same per-user name resolve/refresh look up. Absent (or
# a non-per-user provider) => the default single-account name, preserving prior behavior.
per_user = cfg.get("per_user_context_keys") or []
ctx = (body.context if body else None) or {}
user_ctx = {k: ctx[k] for k in per_user if k in ctx}
if user_ctx:
state_claims["ctx"] = user_ctx
state = create_state_token(state_claims)
q = {
"response_type": "code",
"client_id": str(client_id),
"redirect_uri": _redirect_uri(cfg),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
if cfg.get("scope"):
q["scope"] = cfg["scope"]
return {"authorize_url": f"{cfg['authorize_url']}?{urlencode(q)}"}
@router.get("/v1/oauth/callback", response_class=HTMLResponse)
async def oauth_callback(
code: str | None = None, state: str | None = None, error: str | None = None,
session: AsyncSession = Depends(get_session),
):
# Every interpolated value below is provider/redirect-controlled, so HTML-escape it to
# avoid reflected XSS on the API origin's callback page (audit S9).
if error:
return HTMLResponse(f"<h3>Authorization failed</h3><p>{escape(error)}</p>", status_code=400)
if not code or not state:
return HTMLResponse("<h3>Missing code/state</h3>", status_code=400)
try:
claims = decode_token(state, expected_type="oauth_state")
except TokenError:
# Don't reflect the decode error detail on this public callback page; the generic
# message is enough for the user and the specifics aren't security-relevant to them.
return HTMLResponse("<h3>Invalid or expired state</h3>", status_code=400)
tenant_id, project_id, ap_id = claims["tid"], claims["pid"], claims["ap"]
ap = await _load(session, tenant_id, project_id, ap_id)
cfg = ap.config or {}
secrets = SecretStore()
client_id = await secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) if cfg.get("client_id_ref") else None
client_secret = await secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_secret_ref"]) if cfg.get("client_secret_ref") else None
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(cfg),
"client_id": str(client_id) if client_id else None,
"client_secret": str(client_secret) if client_secret else None,
# PKCE proof matching the code_challenge sent at /start (finding i).
"code_verifier": claims.get("cv"),
}
# Fetch the token through the SSRF guard (validates the host pre-connect AND re-validates
# any redirect hop, with httpx's cross-origin credential stripping) rather than a raw POST
# that would follow a redirect to an internal host (audit S8).
r = await guarded_request(
shared_async_client(), "POST", cfg["token_url"],
data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True,
)
if r.status_code >= 400:
return HTMLResponse(
f"<h3>Token exchange failed ({escape(str(r.status_code))})</h3><pre>{escape(r.text[:500])}</pre>",
status_code=400,
)
body = r.json()
import time as _t
bundle = {
"access_token": body.get("access_token"),
"refresh_token": body.get("refresh_token"),
"token_type": body.get("token_type", "Bearer"),
"scope": body.get("scope", cfg.get("scope")),
"expires_at": (_t.time() + int(body["expires_in"])) if body.get("expires_in") else None,
}
# Store under the SAME (possibly per-user) secret name resolve/refresh read. The end-user
# context was carried through the signed state from /start; without it (shared account) this
# is the default name. Previously the connect always wrote the default name, so a per-user
# provider's token was stored where resolve never looked (finding i / item 5).
bundle_name = AuthResolver.bundle_secret_name(ap_id, claims.get("ctx"), cfg.get("per_user_context_keys"))
await AuthResolver()._store_bundle(tenant_id, project_id, ap_id, bundle, name=bundle_name)
return HTMLResponse("<h3>✅ Connected</h3><p>You can close this window and return to Forge.</p>")
@router.get(_PREFIX + "/status")
async def oauth_status(
project_id: str, ap_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("viewer")),
):
await _load(session, tenant_id, project_id, ap_id) # validates existence/kind
try:
bundle = await SecretStore().read_ref(
tenant_id=tenant_id, project_id=project_id,
ref=f"secret://proj/{AuthResolver.bundle_secret_name(ap_id)}",
)
except SecretNotFound:
return {"connected": False}
return {
"connected": bool(isinstance(bundle, dict) and bundle.get("access_token")),
"expires_at": bundle.get("expires_at") if isinstance(bundle, dict) else None,
"scope": bundle.get("scope") if isinstance(bundle, dict) else None,
"has_refresh": bool(isinstance(bundle, dict) and bundle.get("refresh_token")),
}
+43
View File
@@ -0,0 +1,43 @@
"""Admin-editable model pricing (overlays the built-in defaults)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, get_session, require_role
from forge.models import ModelPrice
from forge.tracing.pricing import load_overrides, merged_prices, set_override
router = APIRouter(prefix="/v1/pricing", tags=["pricing"])
class PriceIn(BaseModel):
input_per_1m: float
output_per_1m: float
async def load_pricing_overrides(session) -> None:
rows = (await session.execute(select(ModelPrice))).scalars()
load_overrides({r.model: (r.input_per_1m, r.output_per_1m) for r in rows})
@router.get("")
async def list_pricing(_: CurrentUser = Depends(require_role("admin"))):
return {m: {"input_per_1m": i, "output_per_1m": o} for m, (i, o) in sorted(merged_prices().items())}
@router.put("/{model}")
async def set_pricing(model: str, body: PriceIn, session: AsyncSession = Depends(get_session),
_: CurrentUser = Depends(require_role("admin"))):
existing = (await session.execute(select(ModelPrice).where(ModelPrice.model == model))).scalar_one_or_none()
if existing:
existing.input_per_1m = body.input_per_1m
existing.output_per_1m = body.output_per_1m
else:
session.add(ModelPrice(model=model, input_per_1m=body.input_per_1m, output_per_1m=body.output_per_1m))
await session.commit()
set_override(model, body.input_per_1m, body.output_per_1m)
return {"model": model, "input_per_1m": body.input_per_1m, "output_per_1m": body.output_per_1m}
+203
View File
@@ -0,0 +1,203 @@
"""Single project-level run endpoint - the framework's simplest integration surface.
One authenticated POST runs the project's *configured* workflow (a saved project setting,
`config.api_workflow_id`) and does everything the 3-endpoint run API does, in one call:
- new turn -> send `input` (+ `thread_id` to continue a conversation)
- HITL -> send `resume` to answer an interrupt the workflow raised (workflow-driven, NOT
a caller toggle)
- `stream` -> the ONLY per-request knob: True streams SSE frames, False returns one JSON reply
Framework-generic: any project, any auth scheme. Per-user secrets (session/CSRF/bearer/etc.)
travel out-of-band in the `X-Forge-Context` header -> `{{ctx.*}}` in tools, never in the body.
Auth is the platform bearer: a service token for server-to-server callers, or a console JWT.
This is a thin wrapper over the existing run machinery (create_run + stream/resume/
run_to_completion) - it adds no new execution path, only a single friendlier surface over the
per-workflow run API at /v1/projects/{id}/workflows/{wid}/runs.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from forge.config import settings
from forge.deps import (
CurrentUser,
get_current_user,
get_run_service,
get_session,
run_context,
)
from forge.models import Project, Run, Thread, Workflow
from forge.schemas.dto import ProjectRunIn
from forge.services.auth import role_at_least
from forge.services.runs import RunService
from forge.util.ratelimit import idempotency, rate_limiter
router = APIRouter(prefix="/v1/projects/{project_id}", tags=["project-run"])
SSE_HEADERS = {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
async def _configured_workflow(session: AsyncSession, tenant_id: str, project_id: str) -> Workflow:
"""The workflow this project's API runs: the saved `config.api_workflow_id`, else the
active workflow, else the only one. Mirrors the embed's resolution (embed_public._workflow_id)
so the two integration surfaces behave identically."""
proj = (await session.execute(
select(Project).where(Project.tenant_id == tenant_id, Project.id == project_id)
)).scalar_one_or_none()
if proj is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
rows = (await session.execute(
select(Workflow).where(Workflow.project_id == project_id)
)).scalars().all()
wid = (proj.config or {}).get("api_workflow_id")
if wid:
wf = next((w for w in rows if w.id == wid), None)
if wf is not None:
return wf
wf = next((w for w in rows if w.status == "active"), None) or (rows[0] if rows else None)
if wf is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no workflow configured for this project's API")
return wf
def _resolve_end_user(
body: ProjectRunIn, user: CurrentUser, tenant_id: str, project_id: str
) -> dict | None:
"""Same identity rules as POST .../runs: a verified session token wins; else the body
end_user, but a non-editor caller may NOT self-assert roles/entitlements (audit S4)."""
if body.session_token:
from forge.security import TokenError, decode_token
try:
claims = decode_token(body.session_token, expected_type="session")
except TokenError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid session token: {e}") from e
if claims.get("tid") != tenant_id or claims.get("pid") != project_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, "session token is not valid for this project")
return claims.get("end_user") or None
if body.end_user is not None:
eu = body.end_user.model_dump(exclude_none=True)
if not role_at_least(user.role, "editor"):
eu.pop("roles", None)
eu.pop("entitlements", None)
return eu
return None
@router.post("/run")
async def project_run(
project_id: str,
body: ProjectRunIn,
session: AsyncSession = Depends(get_session),
user: CurrentUser = Depends(get_current_user),
run_service: RunService = Depends(get_run_service),
rc: dict | None = Depends(run_context),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
tenant_id = user.tenant_id
# Per-tenant run-creation rate limit (shared bucket with the per-workflow run API).
if not rate_limiter.allow(f"runs:{tenant_id}", rate=settings.run_rate_limit_per_minute, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "run rate limit exceeded; slow down")
# Idempotency for the blind-retry-prone case: a non-streaming new turn re-POSTed with the
# same Idempotency-Key returns the original result instead of running the workflow again
# (duplicate emails/charges/side-effects). Streaming and HITL-resume are live/thread-stateful
# and are not deduped here. Checked BEFORE run creation so the side-effecting run never fires
# twice; the result is stored after completion below.
ik = (
f"projrun:{tenant_id}:{project_id}:{idempotency_key}"
if (idempotency_key and body.resume is None and not body.stream)
else None
)
if ik:
cached = idempotency.get(ik)
if cached is not None:
return cached
# ---- HITL resume: answer an interrupt the workflow raised on this thread ----
if body.resume is not None:
if not body.thread_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"resume requires the thread_id of the interrupted conversation",
)
# Resolve the interrupted run by either thread handle the caller may hold - the DB
# Thread.id or the composite LangGraph id - mirroring create_run's thread reuse.
run = (await session.execute(
select(Run).join(Thread, Thread.id == Run.thread_id).where(
Run.tenant_id == tenant_id, Run.project_id == project_id,
or_(Thread.id == body.thread_id, Thread.lg_thread_id == body.thread_id),
Run.status == "interrupted",
).order_by(Run.created_at.desc())
)).scalars().first()
if run is None:
raise HTTPException(status.HTTP_409_CONFLICT, "no interrupted run to resume on this thread")
value = body.resume.value
if body.stream:
async def gen_resume():
# Lead with the canonical thread_id so the streaming caller can keep the
# conversation going (the run frame from stream() carries the LangGraph id).
yield {"event": "ready", "data": json.dumps({"run_id": run.id, "thread_id": run.thread_id})}
async for frame in run_service.stream(
run_id=run.id, tenant_id=tenant_id, project_id=project_id,
run_context=rc, resume=True, resume_value=value,
):
# `id` lets a disconnected client reattach via GET .../runs/{run_id}/stream
# with Last-Event-ID (the run keeps executing regardless - finding #12).
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
return EventSourceResponse(gen_resume(), headers=SSE_HEADERS)
result = await run_service.resume(
run_id=run.id, tenant_id=tenant_id, value=value, project_id=project_id, run_context=rc,
)
result.setdefault("run_id", run.id)
result["thread_id"] = run.thread_id
return result
# ---- new turn: run the project's configured workflow ----
wf = await _configured_workflow(session, tenant_id, project_id)
end_user = _resolve_end_user(body, user, tenant_id, project_id)
# Enforce the tenant DAILY quota atomically with run creation (audit F2).
from forge.services.budget import BudgetExceeded, ModelNotAllowed
from forge.services.quota import QuotaExceeded, run_admission
try:
async with run_admission(session, tenant_id):
run = await run_service.create_run(
session, tenant_id=tenant_id, project_id=project_id, workflow_id=wf.id,
input=body.input or {}, thread_id=body.thread_id, end_user=end_user, source="api",
)
except QuotaExceeded as e:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
except ModelNotAllowed as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, e.message) from e
except BudgetExceeded as e:
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, e.message) from e
if body.stream:
async def gen_new():
yield {"event": "ready", "data": json.dumps({"run_id": run.id, "thread_id": run.thread_id})}
async for frame in run_service.stream(
run_id=run.id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
):
# `id` lets a disconnected client reattach via GET .../runs/{run_id}/stream
# with Last-Event-ID (the run keeps executing regardless - finding #12).
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
return EventSourceResponse(gen_new(), headers=SSE_HEADERS)
result = await run_service.run_to_completion(
run_id=run.id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
)
result["thread_id"] = run.thread_id
if ik:
idempotency.put(ik, result)
return result
+148
View File
@@ -0,0 +1,148 @@
"""Project endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, client_ip, current_tenant_id, get_session, require_role
from forge.schemas.dto import ProjectCountsOut, ProjectCreate, ProjectOut, ProjectUpdate
from forge.services.audit import AuditService
from forge.services.projects import ProjectService
from forge.services.versions import safe_snapshot
router = APIRouter(prefix="/v1/projects", tags=["projects"])
class ProjectMemberIn(BaseModel):
role: str # owner|admin|editor|viewer - the caller's per-project role for {user_id}
@router.get("", response_model=list[ProjectOut])
async def list_projects(
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)
):
return await ProjectService.list(session, tenant_id)
@router.post("", response_model=ProjectOut, status_code=201)
async def create_project(
body: ProjectCreate,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("admin")),
):
project = await ProjectService.create(
session, tenant_id, name=body.name, slug=body.slug,
description=body.description, config=body.config,
)
await safe_snapshot(session, "project", project, author=user)
return project
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
project = await ProjectService.get(session, tenant_id, project_id)
if project is None:
raise HTTPException(404, "Project not found")
return project
@router.get("/{project_id}/counts", response_model=ProjectCountsOut)
async def project_counts(
project_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
"""Lightweight per-resource counts for the project sidebar badges
({workflows, agents, tools, components, knowledge, auth})."""
return await ProjectService.counts(session, tenant_id, project_id)
@router.patch("/{project_id}", response_model=ProjectOut)
async def update_project(
project_id: str,
body: ProjectUpdate,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("admin")),
):
project = await ProjectService.get(session, tenant_id, project_id)
if project is None:
raise HTTPException(404, "Project not found")
project = await ProjectService.update(session, project, name=body.name, description=body.description, config=body.config)
await safe_snapshot(session, "project", project, author=user)
return project
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin")),
):
project = await ProjectService.get(session, tenant_id, project_id)
if project is None:
raise HTTPException(404, "Project not found")
await ProjectService.delete(session, project, checkpointer=getattr(request.app.state, "checkpointer", None))
# --- per-project membership / RBAC (finding h) ---
@router.get("/{project_id}/members")
async def list_project_members(
project_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin")),
):
members = await ProjectService.list_members(session, tenant_id, project_id)
return [{"user_id": m.user_id, "role": m.role} for m in members]
@router.put("/{project_id}/members/{user_id}")
async def set_project_member(
project_id: str,
user_id: str,
body: ProjectMemberIn,
request: Request,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
admin: CurrentUser = Depends(require_role("admin")),
):
"""Grant/update a user's role ON THIS PROJECT. Elevates their tenant-wide role for this
project only (never demotes it - see deps.effective_role)."""
if await ProjectService.get(session, tenant_id, project_id) is None:
raise HTTPException(404, "Project not found")
try:
m = await ProjectService.set_member(session, tenant_id=tenant_id, project_id=project_id,
user_id=user_id, role=body.role)
except ValueError as e:
raise HTTPException(400, str(e)) from e
await AuditService.log(tenant_id=tenant_id, action="project.member.set", actor_id=admin.id,
actor_email=admin.email, resource_type="project", resource_id=project_id,
project_id=project_id, ip=client_ip(request),
meta={"user_id": user_id, "role": body.role})
return {"user_id": m.user_id, "role": m.role}
@router.delete("/{project_id}/members/{user_id}", status_code=204)
async def remove_project_member(
project_id: str,
user_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
admin: CurrentUser = Depends(require_role("admin")),
):
if not await ProjectService.remove_member(session, tenant_id=tenant_id, project_id=project_id, user_id=user_id):
raise HTTPException(404, "membership not found")
await AuditService.log(tenant_id=tenant_id, action="project.member.remove", actor_id=admin.id,
actor_email=admin.email, resource_type="project", resource_id=project_id,
project_id=project_id, ip=client_ip(request), meta={"user_id": user_id})
+174
View File
@@ -0,0 +1,174 @@
"""Run endpoints - create a run and stream its execution over SSE."""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from forge.config import settings
from forge.deps import (
CurrentUser,
current_tenant_id,
get_current_user,
get_run_service,
get_session,
run_context,
)
from forge.schemas.dto import ResumeIn, RunCreate, RunOut
from forge.services.runs import RunService
from forge.util.ratelimit import idempotency, rate_limiter
router = APIRouter(prefix="/v1/projects/{project_id}/workflows/{workflow_id}/runs", tags=["runs"])
SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
}
@router.post("", response_model=RunOut, status_code=201)
async def create_run(
project_id: str,
workflow_id: str,
body: RunCreate,
session: AsyncSession = Depends(get_session),
user: CurrentUser = Depends(get_current_user),
run_service: RunService = Depends(get_run_service),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
tenant_id = user.tenant_id
# Idempotency: a retried POST with the same key returns the original run instead
# of starting a duplicate (important for at-least-once callers / channel webhooks).
if idempotency_key:
cache_key = f"run:{tenant_id}:{idempotency_key}"
cached = idempotency.get(cache_key)
if cached is not None:
return cached
# Per-tenant run-creation rate limit.
if not rate_limiter.allow(f"runs:{tenant_id}", rate=settings.run_rate_limit_per_minute, per=60):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "run rate limit exceeded; slow down")
# This is the CONSOLE interactive run surface (Playground / Workflow test), so every run acts as
# the LOGGED-IN operator - their identity is authoritative and is NOT overridable from the body.
# That makes per-user auth providers resolve the operator's own connected credential
# (end_user_id = user.id), exactly like the tool /test does; the run also carries who is chatting
# for analytics. The server-to-server Run API (routers/project_run.py) and the public embed
# surface (routers/embed_public.py) are where a different end user is asserted. A machine
# principal (service token / API key) carries no per-user identity, so it runs without one.
end_user = None
if not str(user.id).startswith(("apikey:", "service")):
end_user = {"id": user.id, "email": user.email}
# Per-tenant DAILY quota, enforced atomically with run creation so concurrent POSTs
# can't all pass a stale pre-insert count (audit F2).
from forge.services.budget import BudgetExceeded, ModelNotAllowed
from forge.services.quota import QuotaExceeded, run_admission
try:
async with run_admission(session, tenant_id):
run = await run_service.create_run(
session,
tenant_id=tenant_id,
project_id=project_id,
workflow_id=workflow_id,
input=body.input or {},
thread_id=body.thread_id,
end_user=end_user,
source="playground",
)
except QuotaExceeded as e:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
except ModelNotAllowed as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, e.message) from e
except BudgetExceeded as e:
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, e.message) from e
out = RunOut(id=run.id, status=run.status, thread_id=run.thread_id)
if idempotency_key:
idempotency.put(f"run:{tenant_id}:{idempotency_key}", out)
return out
@router.get("/{run_id}/stream")
async def stream_run(
project_id: str,
workflow_id: str,
run_id: str,
tenant_id: str = Depends(current_tenant_id),
run_service: RunService = Depends(get_run_service),
rc: dict | None = Depends(run_context),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
):
# Reconnect/reattach: a browser's EventSource resends the last id it saw as Last-Event-ID;
# we replay frames after it then follow live. Absent/garbage -> start from the beginning.
start_from = int(last_event_id) if (last_event_id or "").isdigit() else 0
async def event_gen():
async for frame in run_service.stream(
run_id=run_id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
last_event_id=start_from,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
@router.post("/{run_id}/rerun", response_model=RunOut, status_code=201)
async def rerun(
project_id: str,
workflow_id: str,
run_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
run_service: RunService = Depends(get_run_service),
):
"""Replay a past run: create a new run with the same input (fresh thread)."""
from sqlalchemy import select
from forge.models import Run
orig = (await session.execute(select(Run).where(
Run.tenant_id == tenant_id,
Run.project_id == project_id,
Run.workflow_id == workflow_id,
Run.id == run_id,
))).scalar_one_or_none()
if orig is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found")
run = await run_service.create_run(
session, tenant_id=tenant_id, project_id=project_id, workflow_id=workflow_id, input=orig.input or {},
source=getattr(orig, "source", None) or "playground",
)
return RunOut(id=run.id, status=run.status, thread_id=run.thread_id)
@router.post("/{run_id}/resume")
async def resume_run(
project_id: str,
workflow_id: str,
run_id: str,
body: ResumeIn,
tenant_id: str = Depends(current_tenant_id),
run_service: RunService = Depends(get_run_service),
rc: dict | None = Depends(run_context),
):
return await run_service.resume(run_id=run_id, tenant_id=tenant_id, value=body.value, project_id=project_id, run_context=rc)
@router.post("/{run_id}/cancel")
async def cancel_run(
project_id: str,
workflow_id: str,
run_id: str,
tenant_id: str = Depends(current_tenant_id),
run_service: RunService = Depends(get_run_service),
_: CurrentUser = Depends(get_current_user),
):
"""Cancel a run: mark it canceled and cooperatively stop it (frees the tenant-concurrency
slot). A terminal run (done/error/canceled) returns ok=False with its current status."""
result = await run_service.cancel_run(run_id=run_id, tenant_id=tenant_id, project_id=project_id)
if result.get("error") == "run not found":
raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found")
return result
+41
View File
@@ -0,0 +1,41 @@
"""Secret endpoints - write-only; plaintext is never returned."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.dto import SecretCreate, SecretOut
from forge.services.secrets import SecretService
router = APIRouter(prefix="/v1/projects/{project_id}/secrets", tags=["secrets"])
@router.get("", response_model=list[SecretOut])
async def list_secrets(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await SecretService.list(session, tenant_id, project_id)
@router.post("", response_model=SecretOut, status_code=201)
async def create_secret(project_id: str, body: SecretCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin"))):
return await SecretService.write(session, tenant_id, project_id, name=body.name, value=body.value, kind=body.kind)
@router.get("/{name}/usage")
async def secret_usage(project_id: str, name: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
refs = await SecretService.usage(session, tenant_id, project_id, name=name)
return {"count": len(refs), "references": refs}
@router.delete("/{name}", status_code=204)
async def delete_secret(project_id: str, name: str, force: bool = False, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("admin"))):
if not force:
refs = await SecretService.usage(session, tenant_id, project_id, name=name)
if refs:
raise HTTPException(status.HTTP_409_CONFLICT, detail={"message": "Secret is in use", "references": refs})
removed = await SecretService.delete(session, tenant_id, project_id, name=name)
if not removed:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Secret not found")
+535
View File
@@ -0,0 +1,535 @@
"""Dashboard stats - tenant-wide rollups over real traces (no placeholder numbers).
Rollups are computed as SQL aggregates (COUNT / SUM + GROUP BY) so a dashboard load never
pulls a tenant's entire trace history into memory - it returns a handful of grouped rows
regardless of how many traces exist. Derived fields (averages, rates) are computed in
Python from the raw sums/counts so the arithmetic stays identical to the previous
row-by-row implementation.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.db.base import engine
from forge.deps import current_tenant_id, get_session
from forge.models import Project, Span, Tool, Trace, Workflow
router = APIRouter(prefix="/v1/stats", tags=["stats"])
# Postgres (prod) and SQLite (dev/test) format dates differently. Bucket a timestamp to a
# "YYYY-MM-DD" string in SQL - grouped in the database, so a chart load never pulls a
# project's whole trace history into memory - via the right function for the active dialect.
_DIALECT = engine.dialect.name
def _day_bucket(col):
if _DIALECT == "sqlite":
return func.strftime("%Y-%m-%d", col)
return func.to_char(func.date_trunc("day", col), "YYYY-MM-DD")
# --- response models (typed contract for the generated OpenAPI schema) --------------------
class RollupOut(BaseModel):
runs: int
tokens: int
cost_usd: float
avg_latency_ms: int
errors: int
error_rate: float
class RecentRunOut(BaseModel):
id: str
workflow: str
project: str
status: str
tokens: int
latency_ms: int
cost_usd: float
started_at: str | None = None
class ProjectCardStatsOut(BaseModel):
workflows: int
tools: int
runs_7d: int
class DashboardReportOut(RollupOut):
project_id: str
project: str
assistant_cost_usd: float
assistant_turns: int
class DashboardStatsOut(BaseModel):
runs_7d: int
total_runs: int
success_rate: float
avg_latency_ms: int
spend_7d: float
recent: list[RecentRunOut]
projects: dict[str, ProjectCardStatsOut]
reports: list[DashboardReportOut]
totals: RollupOut
class ReportRowOut(RollupOut):
label: str
kind: str
class AssistantRollupOut(RollupOut):
turns: int
class ProjectStatsOut(BaseModel):
totals: RollupOut
last_7d: RollupOut
assistant: AssistantRollupOut
reports: list[ReportRowOut]
# --- analytics dashboard (time-series + breakdowns over a date range) ---------------------
class TimeBucketOut(BaseModel):
date: str # "YYYY-MM-DD" (one point per day across the whole range, gaps zero-filled)
runs: int
tokens: int
cost_usd: float
avg_latency_ms: int
errors: int
success: int
class SourceRollupOut(RollupOut):
source: str
class ToolStatOut(BaseModel):
name: str
calls: int
avg_latency_ms: int
errors: int
cost_usd: float
tokens: int
class ModelStatOut(BaseModel):
model: str
calls: int
tokens: int
cost_usd: float
avg_latency_ms: int
class LatencyBucketOut(BaseModel):
label: str
count: int
class AnalyticsRangeOut(BaseModel):
days: int
since: str
until: str
bucket: str
class AnalyticsOut(BaseModel):
range: AnalyticsRangeOut
totals: RollupOut # windowed over the selected range
prev_totals: RollupOut # the immediately-preceding window of equal length (for deltas)
timeseries: list[TimeBucketOut]
by_source: list[SourceRollupOut]
by_workflow: list[ReportRowOut]
tools: list[ToolStatOut]
models: list[ModelStatOut]
latency_histogram: list[LatencyBucketOut]
recent: list[RecentRunOut]
# When a trace has no start time, fall back to its insert time (matches the old
# `t.started_at or t.created_at`) for the 7-day activity window.
_ACTIVITY = func.coalesce(Trace.started_at, Trace.created_at)
def _agg_columns():
"""The five raw aggregates every rollup needs. Derived fields (avg, rate) come from these.
Returned fresh each call so the same expressions can be reused across queries."""
return (
func.count().label("runs"),
func.coalesce(func.sum(Trace.total_tokens), 0).label("tokens"),
func.coalesce(func.sum(Trace.total_cost_usd), 0.0).label("cost"),
func.coalesce(func.sum(Trace.latency_ms), 0).label("latency_sum"),
func.coalesce(func.sum(case((Trace.status == "error", 1), else_=0)), 0).label("errors"),
)
def _rollup(row) -> dict:
"""Fold one aggregate row (runs/tokens/cost/latency_sum/errors) into the rollup shape."""
runs = int(row.runs or 0)
errors = int(row.errors or 0)
return {
"runs": runs,
"tokens": int(row.tokens or 0),
"cost_usd": round(float(row.cost or 0.0), 6),
"avg_latency_ms": int((row.latency_sum or 0) / runs) if runs else 0,
"errors": errors,
"error_rate": round(errors / runs * 100, 1) if runs else 0.0,
}
@router.get("/dashboard", response_model=DashboardStatsOut)
async def dashboard(session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
since = datetime.utcnow() - timedelta(days=7)
tenant = Trace.tenant_id == tenant_id
# All-time totals (one row).
totals = _rollup((await session.execute(select(*_agg_columns()).where(tenant))).one())
# 7-day window aggregate (one row): count, success count, spend, latency sum.
win = (await session.execute(
select(
func.count().label("runs"),
func.coalesce(func.sum(case((Trace.status.in_(("done", "interrupted")), 1), else_=0)), 0).label("done"),
func.coalesce(func.sum(Trace.total_cost_usd), 0.0).label("cost"),
func.coalesce(func.sum(Trace.latency_ms), 0).label("latency_sum"),
).where(tenant, _ACTIVITY >= since)
)).one()
total = int(win.runs or 0)
done = int(win.done or 0)
avg_latency = int((win.latency_sum or 0) / total) if total else 0
# Per-project counts for the dashboard cards (workflows, tools, 7-day runs).
per_project: dict[str, dict] = {}
def bucket(pid: str) -> dict:
return per_project.setdefault(pid, {"workflows": 0, "tools": 0, "runs_7d": 0})
for pid, n in (await session.execute(
select(Workflow.project_id, func.count()).where(Workflow.tenant_id == tenant_id).group_by(Workflow.project_id)
)).all():
bucket(pid)["workflows"] = int(n)
for pid, n in (await session.execute(
select(Tool.project_id, func.count()).where(Tool.tenant_id == tenant_id).group_by(Tool.project_id)
)).all():
bucket(pid)["tools"] = int(n)
for pid, n in (await session.execute(
select(Trace.project_id, func.count()).where(tenant, _ACTIVITY >= since).group_by(Trace.project_id)
)).all():
bucket(pid)["runs_7d"] = int(n)
# Name lookups (bounded by #workflows / #projects, not #traces).
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id)
)).all()}
proj_names: dict[str, str] = {pid: name for pid, name in (await session.execute(
select(Project.id, Project.name).where(Project.tenant_id == tenant_id)
)).all()}
# 8 most recent all-time - only the columns the card renders.
recent_rows = (await session.execute(
select(
Trace.id, Trace.workflow_id, Trace.project_id, Trace.status,
Trace.total_tokens, Trace.latency_ms, Trace.total_cost_usd, Trace.started_at,
).where(tenant).order_by(Trace.started_at.desc()).limit(8)
)).all()
recent = [
{
"id": r.id,
"workflow": wf_names.get(r.workflow_id or "", "run"),
"project": proj_names.get(r.project_id, "-"),
"status": r.status,
"tokens": r.total_tokens,
"latency_ms": r.latency_ms,
"cost_usd": round(r.total_cost_usd or 0.0, 6),
"started_at": r.started_at.isoformat() if r.started_at else None,
}
for r in recent_rows
]
# Per-project report rows (all-time); assistant share via conditional aggregation.
report_rows = (await session.execute(
select(
Trace.project_id,
*_agg_columns(),
func.coalesce(func.sum(case((Trace.name == "assistant", Trace.total_cost_usd), else_=0.0)), 0.0).label("asst_cost"),
func.coalesce(func.sum(case((Trace.name == "assistant", 1), else_=0)), 0).label("asst_turns"),
).where(tenant).group_by(Trace.project_id)
)).all()
reports = [
{
"project_id": r.project_id,
"project": proj_names.get(r.project_id, "(deleted project)"),
**_rollup(r),
"assistant_cost_usd": round(float(r.asst_cost or 0.0), 6),
"assistant_turns": int(r.asst_turns or 0),
}
for r in report_rows
]
reports.sort(key=lambda r: r["cost_usd"], reverse=True)
return {
"runs_7d": total,
"total_runs": totals["runs"],
"success_rate": round(done / total * 100, 1) if total else 0.0,
"avg_latency_ms": avg_latency,
"spend_7d": round(float(win.cost or 0.0), 6),
"recent": recent,
"projects": per_project,
"reports": reports,
"totals": totals,
}
@router.get("/projects/{project_id}", response_model=ProjectStatsOut)
async def project_stats(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
"""Project-scoped rollups + report rows (per workflow + Forge Assistant)."""
since = datetime.utcnow() - timedelta(days=7)
scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id)
totals = _rollup((await session.execute(select(*_agg_columns()).where(*scope))).one())
last_7d = _rollup((await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= since))).one())
asst = (await session.execute(select(*_agg_columns()).where(*scope, Trace.name == "assistant"))).one()
assistant = {**_rollup(asst), "turns": int(asst.runs or 0)}
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id, Workflow.project_id == project_id)
)).all()}
# Report rows grouped like the old _report_rows: one bucket for the assistant, one per
# workflow_id, and an "other" bucket keyed by trace name for runs with no workflow.
kind = case(
(Trace.name == "assistant", "assistant"),
(Trace.workflow_id.isnot(None), "workflow"),
else_="other",
).label("kind")
ident = case(
(Trace.name == "assistant", "assistant"),
(Trace.workflow_id.isnot(None), Trace.workflow_id),
else_=Trace.name,
).label("ident")
grouped = (await session.execute(
select(kind, ident, *_agg_columns()).where(*scope).group_by(kind, ident)
)).all()
reports = []
for r in grouped:
if r.kind == "assistant":
label = "Forge Assistant"
elif r.kind == "workflow":
label = wf_names.get(r.ident, "(deleted workflow)")
else:
label = r.ident
reports.append({"label": label, "kind": r.kind, **_rollup(r)})
reports.sort(key=lambda r: r["cost_usd"], reverse=True)
return {
"totals": totals,
"last_7d": last_7d,
"assistant": assistant,
"reports": reports,
}
# Fixed latency buckets for the distribution histogram (upper bound in ms; None = open-ended).
_LATENCY_BUCKETS: list[tuple[str, int | None]] = [
("<250ms", 250), ("250-500ms", 500), ("500ms-1s", 1000), ("1-2s", 2000),
("2-5s", 5000), ("5-10s", 10000), (">10s", None),
]
def _ts_row(row) -> dict:
"""Fold one daily aggregate row into a time-series point (adds a success count)."""
runs = int(row.runs or 0)
return {
"date": row.day,
"runs": runs,
"tokens": int(row.tokens or 0),
"cost_usd": round(float(row.cost or 0.0), 6),
"avg_latency_ms": int((row.latency_sum or 0) / runs) if runs else 0,
"errors": int(row.errors or 0),
"success": int(row.success or 0),
}
@router.get("/projects/{project_id}/analytics", response_model=AnalyticsOut)
async def project_analytics(
project_id: str,
days: int = 30,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
"""Time-series + breakdowns for the project Analytics dashboard, over the last `days`.
Everything is a grouped SQL aggregate (daily buckets, per-source, per-workflow, and
per-tool/per-model spans) so a dashboard load returns a bounded number of rows no matter
how large the trace history is. The previous equal-length window is rolled up too, so the
UI can show period-over-period deltas on each KPI.
"""
days = max(1, min(days, 365))
now = datetime.utcnow()
since = now - timedelta(days=days)
prev_since = since - timedelta(days=days)
scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id)
span_scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id, _ACTIVITY >= since)
totals = _rollup((await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= since))).one())
prev_totals = _rollup(
(await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= prev_since, _ACTIVITY < since))).one()
)
# Daily time-series: one grouped row per calendar day present, then zero-fill the gaps so
# the chart draws a continuous line across the whole range.
day = _day_bucket(_ACTIVITY).label("day")
ts_rows = (await session.execute(
select(
day, *_agg_columns(),
func.coalesce(func.sum(case((Trace.status.in_(("done", "interrupted")), 1), else_=0)), 0).label("success"),
).where(*scope, _ACTIVITY >= since).group_by(day).order_by(day)
)).all()
by_day = {r.day: _ts_row(r) for r in ts_rows}
timeseries: list[dict] = []
cursor = since.date()
end = now.date()
while cursor <= end:
key = cursor.isoformat()
timeseries.append(by_day.get(key) or {
"date": key, "runs": 0, "tokens": 0, "cost_usd": 0.0, "avg_latency_ms": 0, "errors": 0, "success": 0,
})
cursor += timedelta(days=1)
# Per-source rollup (playground / api / embed / channels / assistant / ...).
src_rows = (await session.execute(
select(Trace.source, *_agg_columns()).where(*scope, _ACTIVITY >= since).group_by(Trace.source)
)).all()
by_source = [{"source": r.source or "-", **_rollup(r)} for r in src_rows]
by_source.sort(key=lambda r: r["runs"], reverse=True)
# Per-workflow (+ assistant + name-keyed "other") report rows, same shape as project_stats.
kind = case(
(Trace.name == "assistant", "assistant"),
(Trace.workflow_id.isnot(None), "workflow"),
else_="other",
).label("kind")
ident = case(
(Trace.name == "assistant", "assistant"),
(Trace.workflow_id.isnot(None), Trace.workflow_id),
else_=Trace.name,
).label("ident")
grouped = (await session.execute(
select(kind, ident, *_agg_columns()).where(*scope, _ACTIVITY >= since).group_by(kind, ident)
)).all()
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id, Workflow.project_id == project_id)
)).all()}
by_workflow = []
for r in grouped:
if r.kind == "assistant":
label = "Forge Assistant"
elif r.kind == "workflow":
label = wf_names.get(r.ident, "(deleted workflow)")
else:
label = r.ident
by_workflow.append({"label": label, "kind": r.kind, **_rollup(r)})
by_workflow.sort(key=lambda r: r["cost_usd"], reverse=True)
# Tool + model breakdowns from spans, joined to their trace for tenant/project/window scope.
span_lat = func.coalesce(func.sum(Span.latency_ms), 0).label("latency_sum")
span_calls = func.count().label("calls")
span_tokens = func.coalesce(func.sum(Span.input_tokens + Span.output_tokens), 0).label("tokens")
span_cost = func.coalesce(func.sum(Span.cost_usd), 0.0).label("cost")
tool_rows = (await session.execute(
select(
Span.name, span_calls, span_lat, span_tokens, span_cost,
func.coalesce(func.sum(case((Span.error.isnot(None), 1), else_=0)), 0).label("errors"),
).join(Trace, Trace.id == Span.trace_id)
.where(*span_scope, Span.kind == "tool")
.group_by(Span.name).order_by(span_calls.desc()).limit(12)
)).all()
tools = [
{
"name": r.name,
"calls": int(r.calls or 0),
"avg_latency_ms": int((r.latency_sum or 0) / r.calls) if r.calls else 0,
"errors": int(r.errors or 0),
"cost_usd": round(float(r.cost or 0.0), 6),
"tokens": int(r.tokens or 0),
}
for r in tool_rows
]
model_rows = (await session.execute(
select(Span.model, span_calls, span_lat, span_tokens, span_cost)
.join(Trace, Trace.id == Span.trace_id)
.where(*span_scope, Span.kind == "llm", Span.model.isnot(None))
.group_by(Span.model).order_by(span_cost.desc()).limit(12)
)).all()
models = [
{
"model": r.model,
"calls": int(r.calls or 0),
"tokens": int(r.tokens or 0),
"cost_usd": round(float(r.cost or 0.0), 6),
"avg_latency_ms": int((r.latency_sum or 0) / r.calls) if r.calls else 0,
}
for r in model_rows
]
# Latency distribution: one row, a conditional count per bucket (SQL-side, no row scan).
hist_cols = []
lo = 0
for i, (_, hi) in enumerate(_LATENCY_BUCKETS):
if hi is None:
cond = Trace.latency_ms >= lo
elif lo == 0:
cond = Trace.latency_ms < hi
else:
cond = (Trace.latency_ms >= lo) & (Trace.latency_ms < hi)
hist_cols.append(func.coalesce(func.sum(case((cond, 1), else_=0)), 0).label(f"b{i}"))
lo = hi or lo
hist_row = (await session.execute(select(*hist_cols).where(*scope, _ACTIVITY >= since))).one()
latency_histogram = [
{"label": label, "count": int(getattr(hist_row, f"b{i}") or 0)}
for i, (label, _) in enumerate(_LATENCY_BUCKETS)
]
# 8 most recent runs in the window (for the activity feed).
recent_rows = (await session.execute(
select(
Trace.id, Trace.workflow_id, Trace.name, Trace.status,
Trace.total_tokens, Trace.latency_ms, Trace.total_cost_usd, Trace.started_at,
).where(*scope, _ACTIVITY >= since).order_by(Trace.started_at.desc()).limit(8)
)).all()
recent = [
{
"id": r.id,
"workflow": wf_names.get(r.workflow_id or "", r.name or "run"),
"project": "",
"status": r.status,
"tokens": r.total_tokens,
"latency_ms": r.latency_ms,
"cost_usd": round(r.total_cost_usd or 0.0, 6),
"started_at": r.started_at.isoformat() if r.started_at else None,
}
for r in recent_rows
]
return {
"range": {"days": days, "since": since.isoformat(), "until": now.isoformat(), "bucket": "day"},
"totals": totals,
"prev_totals": prev_totals,
"timeseries": timeseries,
"by_source": by_source,
"by_workflow": by_workflow,
"tools": tools,
"models": models,
"latency_histogram": latency_histogram,
"recent": recent,
}
+86
View File
@@ -0,0 +1,86 @@
"""Tool Set endpoints (CRUD + membership).
A tool set is a describable group of tools (see forge.services.tool_sets). Sets organize the
Tools screen, can be granted to an agent as a unit, and are the unit published over MCP.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.models import ToolSet
from forge.schemas.dto import ToolSetCreate, ToolSetOut, ToolSetUpdate
from forge.services.tool_sets import ToolSetService
router = APIRouter(prefix="/v1/projects/{project_id}/tool-sets", tags=["tool-sets"])
def _to_out(ts: ToolSet, tool_ids: list[str]) -> ToolSetOut:
return ToolSetOut(
id=ts.id, project_id=ts.project_id, name=ts.name, slug=ts.slug,
description=ts.description or "", icon=ts.icon, is_default=ts.is_default, exposed=ts.exposed, tool_ids=tool_ids,
)
async def _load(session: AsyncSession, tenant_id: str, project_id: str, set_id: str) -> ToolSet:
ts = await ToolSetService.get(session, tenant_id, set_id)
if ts is None or ts.project_id != project_id:
raise HTTPException(404, "Tool set not found")
return ts
@router.get("", response_model=list[ToolSetOut])
async def list_tool_sets(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
sets = await ToolSetService.list(session, tenant_id, project_id)
members = await ToolSetService.members_map(session, tenant_id, project_id)
return [_to_out(s, members.get(s.id, [])) for s in sets]
@router.post("", response_model=ToolSetOut, status_code=201)
async def create_tool_set(project_id: str, body: ToolSetCreate, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
ts = await ToolSetService.create(
session, tenant_id, project_id, name=body.name, description=body.description,
icon=body.icon, is_default=body.is_default, exposed=body.exposed, tool_ids=body.tool_ids,
)
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
@router.get("/{set_id}", response_model=ToolSetOut)
async def get_tool_set(project_id: str, set_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
ts = await _load(session, tenant_id, project_id, set_id)
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
@router.patch("/{set_id}", response_model=ToolSetOut)
async def update_tool_set(project_id: str, set_id: str, body: ToolSetUpdate, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
ts = await _load(session, tenant_id, project_id, set_id)
ts = await ToolSetService.update(
session, ts, name=body.name, description=body.description, icon=body.icon,
is_default=body.is_default, exposed=body.exposed, tool_ids=body.tool_ids,
)
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
@router.delete("/{set_id}", status_code=204)
async def delete_tool_set(project_id: str, set_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
ts = await _load(session, tenant_id, project_id, set_id)
await ToolSetService.delete(session, ts)
@router.post("/{set_id}/tools/{tool_id}", status_code=204)
async def add_tool_to_set(project_id: str, set_id: str, tool_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
ts = await _load(session, tenant_id, project_id, set_id)
await ToolSetService.add_member(session, ts, tool_id)
@router.delete("/{set_id}/tools/{tool_id}", status_code=204)
async def remove_tool_from_set(project_id: str, set_id: str, tool_id: str, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
ts = await _load(session, tenant_id, project_id, set_id)
await ToolSetService.remove_member(session, ts, tool_id)
+113
View File
@@ -0,0 +1,113 @@
"""Tool endpoints (CRUD + /test)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
from forge.schemas.contracts import validate_against_id
from forge.schemas.dto import (
ExportIn,
ImportIn,
ImportReport,
ToolCreate,
ToolOut,
ToolTestIn,
ToolUpdate,
)
from forge.services.portability import PortabilityService
from forge.services.tools import ToolService
from forge.services.versions import safe_snapshot
router = APIRouter(prefix="/v1/projects/{project_id}/tools", tags=["tools"])
@router.post("/export")
async def export_tools(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id)):
"""Serialize the selected tools into a downloadable single-type bundle."""
return await PortabilityService.export(session, tenant_id, project_id, "tool", body.ids)
@router.post("/import", response_model=ImportReport)
async def import_tools(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
"""Create tools from an uploaded bundle in THIS project (new ids, auto-renamed on collision)."""
if body.type not in (None, "tool"):
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
try:
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
except ValueError as e:
raise HTTPException(422, str(e)) from e
@router.get("", response_model=list[ToolOut])
async def list_tools(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
# Built-ins are project defaults: make sure this project has them before listing (idempotent),
# so every project - including freshly imported ones - always has the platform capabilities.
await ToolService.ensure_builtins(session, tenant_id, project_id)
return await ToolService.list(session, tenant_id, project_id)
@router.post("", response_model=ToolOut, status_code=201)
async def create_tool(project_id: str, body: ToolCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
cfg = {**body.config, "name": body.name, "kind": body.kind}
errors = validate_against_id(cfg, "forge/tool")
if errors:
raise HTTPException(422, detail={"errors": errors})
tool = await ToolService.create(session, tenant_id, project_id, name=body.name, kind=body.kind, config=body.config, auth_provider_id=body.auth_provider_id)
await safe_snapshot(session, "tool", tool, author=user)
return tool
@router.get("/{tool_id}", response_model=ToolOut)
async def get_tool(project_id: str, tool_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
tool = await ToolService.get(session, tenant_id, tool_id)
if tool is None:
raise HTTPException(404, "Tool not found")
return tool
@router.patch("/{tool_id}", response_model=ToolOut)
async def update_tool(project_id: str, tool_id: str, body: ToolUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
tool = await ToolService.get(session, tenant_id, tool_id)
if tool is None:
raise HTTPException(404, "Tool not found")
if body.config is not None:
cfg = {**body.config, "name": body.name or tool.name, "kind": tool.kind}
errors = validate_against_id(cfg, "forge/tool")
if errors:
raise HTTPException(422, detail={"errors": errors})
tool = await ToolService.update(session, tool, name=body.name, config=body.config, auth_provider_id=body.auth_provider_id, enabled=body.enabled)
await safe_snapshot(session, "tool", tool, author=user)
return tool
@router.delete("/{tool_id}", status_code=204)
async def delete_tool(project_id: str, tool_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
_: CurrentUser = Depends(require_role("editor"))):
tool = await ToolService.get(session, tenant_id, tool_id)
if tool is None:
raise HTTPException(404, "Tool not found")
if tool.kind == "builtin":
raise HTTPException(409, "Built-in tools are platform capabilities and cannot be deleted. Disable it instead.")
await ToolService.delete(session, tool)
@router.post("/{tool_id}/test")
async def test_tool(project_id: str, tool_id: str, body: ToolTestIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
user: CurrentUser = Depends(require_role("editor"))):
tool = await ToolService.get(session, tenant_id, tool_id)
if tool is None:
raise HTTPException(404, "Tool not found")
cfg = {**(tool.config or {}), "name": tool.name, "kind": tool.kind, "auth_provider_id": tool.auth_provider_id or (tool.config or {}).get("auth_provider_id")}
# Console tests run AS the current user, so a PER-USER auth provider resolves the tester's own
# connected credential (end_user_id keys the per-user bundle - the same id the MCP PAT resolves
# to). The run-context field can still override it.
ctx = {"end_user_id": user.id, **(body.context or {})}
result = await ToolService.test(tenant_id, project_id, cfg, body.args, ctx)
await ToolService.record_test(session, tool, result)
return result
+26
View File
@@ -0,0 +1,26 @@
"""Trace endpoints - runs list + span detail."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from forge.deps import current_tenant_id, get_session
from forge.schemas.dto import TraceDetailOut, TraceOut
from forge.services.traces import TraceService
router = APIRouter(prefix="/v1/projects/{project_id}/traces", tags=["traces"])
@router.get("", response_model=list[TraceOut])
async def list_traces(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
return await TraceService.list(session, tenant_id, project_id)
@router.get("/{trace_id}", response_model=TraceDetailOut)
async def get_trace(project_id: str, trace_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
trace = await TraceService.get(session, tenant_id, trace_id)
if trace is None:
raise HTTPException(404, "Trace not found")
spans = await TraceService.spans(session, tenant_id, trace_id)
return {"trace": trace, "spans": spans}
+36
View File
@@ -0,0 +1,36 @@
"""List a project's triggers (webhook URLs, schedules) for the console."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from forge.config import settings
from forge.deps import current_tenant_id, get_session
from forge.models import Trigger
router = APIRouter(prefix="/v1/projects/{project_id}/triggers", tags=["triggers"])
@router.get("")
async def list_triggers(
project_id: str,
session: AsyncSession = Depends(get_session),
tenant_id: str = Depends(current_tenant_id),
):
rows = (await session.execute(
select(Trigger).where(Trigger.tenant_id == tenant_id, Trigger.project_id == project_id)
)).scalars()
base = settings.public_base_url.rstrip("/")
out = []
for t in rows:
item = {
"id": t.id, "workflow_id": t.workflow_id, "node_id": t.node_id, "kind": t.kind,
"enabled": t.enabled, "config": t.config,
"last_fired_at": t.last_fired_at.isoformat() if t.last_fired_at else None,
}
if t.kind == "webhook_in" and t.key:
item["webhook_url"] = f"{base}/v1/hooks/{t.key}"
out.append(item)
return out

Some files were not shown because too many files have changed in this diff Show More