Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ For app-surface changes, inspect the proof app at
registration in the frontend build. Keep standalone `dist/` delivery separate.
- Keep generic agent, harness, workspace, sandbox, event, gate, webhook, and
settings plumbing in Druks. Keep domain-specific policy in the app.
- One Druks installation serves one organization. Execution defaults and agent
overrides are shared. Accounts own credentials, personal preferences, and run
attribution. Do not add personal execution defaults.
- Grow the author surface by parameter, not by namespace. If the SDK lacks a
capability, widen the primitive that owns it. Add a keyword argument or a
method to the class that holds the data. Do not add a namespace, facade,
Expand Down
19 changes: 18 additions & 1 deletion backend/druks/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from druks.core.models import Uuid7Pk
from druks.database import db_session
from druks.models import Base
from druks.settings import load_settings
from druks.user_settings.models import InstallationSettings


class Account(Base, Uuid7Pk):
Expand All @@ -36,6 +38,10 @@ class Account(Base, Uuid7Pk):
# stored as the provider gave it and matched regardless of case.
username: Mapped[str] = mapped_column(CITEXT, unique=True)
is_default: Mapped[bool] = mapped_column(default=False, server_default=text("false"))
timezone: Mapped[str] = mapped_column(String, default="UTC")
gate_park_destination_id: Mapped[str | None] = mapped_column(
ForeignKey("notification_destinations.id", ondelete="SET NULL"), default=None
)
created_at: Mapped[datetime] = mapped_column(default=Base.utc_now)

@classmethod
Expand Down Expand Up @@ -65,14 +71,25 @@ async def get_or_create(cls, username: str) -> "Account":
account = await cls.get_for_username(username)
if account:
return account
installation = await InstallationSettings.get()
session = db_session()
await session.execute(
insert(cls)
.values(username=username, is_default=~select(cls.id).exists())
.values(
username=username,
is_default=~select(cls.id).exists(),
timezone=load_settings().timezone,
gate_park_destination_id=installation.gate_park_destination_id,
)
.on_conflict_do_nothing(index_elements=["username"])
)
return (await session.scalars(select(cls).where(cls.username == username))).one()

async def update_preferences(self, **fields: object) -> None:
for field, value in fields.items():
setattr(self, field, value)
await db_session().flush()

@classmethod
async def list_all(cls) -> list["Account"]:
stmt = select(cls).order_by(cls.created_at, cls.id)
Expand Down
38 changes: 19 additions & 19 deletions backend/druks/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
from druks.durable.exceptions import WorkflowError
from druks.durable.models import AgentCall, Artifact
from druks.files.datastructures import File
from druks.harnesses.config import AgentConfig, get_config
from druks.harnesses.exceptions import (
HarnessError,
HarnessInvalidOutputError,
Retry,
)
from druks.harnesses.profiles import Profile, get_profile
from druks.prompts import render_prompt
from druks.sandbox import gate as sandbox_gate
from druks.sandbox.client import provisioning_key, sandbox_client
Expand All @@ -44,14 +44,14 @@

@contextlib.asynccontextmanager
async def _runner(
workflow: "Workflow", host_id: str | None, workflow_id: str, step: str, profile: Profile
workflow: "Workflow", host_id: str | None, workflow_id: str, step: str, config: AgentConfig
) -> AsyncIterator["Workspace"]:
# The agent always runs in a Workspace. A warm run attaches the run's held VM; the
# rest get a fresh ephemeral VM. Either way workflow.get_workspace() turns the VM into
# the runner — fresh per call, so nothing (connection or credential) is held across steps.
if host_id:
vm = sandbox_client.attach(host_id=host_id)
elif (refs := [*profile.secret_refs, *await workflow.get_secret_refs()]) and (
elif (refs := [*config.secret_refs, *await workflow.get_secret_refs()]) and (
identity := await SandboxIdentity.lookup(workflow_id, step, refs)
):
# A crashed attempt left its box behind. Its identity finds it again.
Expand All @@ -63,15 +63,15 @@ async def _runner(
await set_run_phase("provisioning_vm")
# A box that fetches gets its own identity, and the key names it. A
# replay finds the box through the identity, above.
identity, entries, key = None, {}, profile.secrets_id
identity, entries, key = None, {}, config.secrets_id
if refs:
identity, entries = await SandboxIdentity.create(
run_id=workflow_id, scoped_to=step, secret_refs=refs
)
key = identity.id
vm = sandbox_client.ephemeral(
idempotency_key=provisioning_key(workflow_id, step, key),
secrets={**profile.secrets, **entries},
secrets={**config.secrets, **entries},
template=template,
identity=identity,
)
Expand Down Expand Up @@ -140,12 +140,12 @@ def __set_name__(self, owner: type, attr: str) -> None:
object.__setattr__(self, "app", owner.name)
agents.register(self)

async def get_profile(self) -> Profile:
async def get_config(self) -> AgentConfig:
"""How this agent runs for the current run's actor, read from settings now."""
workflow = current_workflow.get(None)
if not workflow:
raise WorkflowError(f"agent {self.id!r} reads its profile only inside a workflow")
return await get_profile(self.id, workflow.account_id)
raise WorkflowError(f"agent {self.id!r} reads its config only inside a workflow")
return await get_config(self.id, workflow.account_id)

async def __call__(
self, *, contract: type[AgentOutput] | None = None, **context: object
Expand Down Expand Up @@ -202,9 +202,9 @@ async def _quota_retry_wait() -> float:
# recorded wait instead of re-reading the scrape.
async with step_session():
# The scrape belongs to the subscription account.
profile = await get_profile(self.id, workflow.account_id)
provider_id = profile.model.partition("/")[0]
scrape = await UsageScrape.latest_for(provider_id, profile.charged_account_id)
config = await get_config(self.id, workflow.account_id)
provider_id = config.model.partition("/")[0]
scrape = await UsageScrape.latest_for(provider_id, config.charged_account_id)
if scrape:
now = datetime.now(UTC)
reset = scrape.soonest_reset_after(now)
Expand Down Expand Up @@ -276,10 +276,10 @@ async def _run(
workflow = current_workflow.get()
# Refusing an unservable call here beats provisioning a VM and
# 401ing mid-run.
profile = await get_profile(self.id, workflow.account_id)
model = profile.model
subscription_id = profile.subscription.id if profile.subscription else None
api_key_id = profile.api_key.id if profile.api_key else None
config = await get_config(self.id, workflow.account_id)
model = config.model
subscription_id = config.subscription.id if config.subscription else None
api_key_id = config.api_key.id if config.api_key else None
# An agent call is a durability boundary — its effects don't roll back —
# so commit here rather than hold the step's connection idle through the
# minutes of provisioning and the run.
Expand All @@ -288,7 +288,7 @@ async def _run(
artifact_dir = settings.artifacts_dir / f"run-{workflow_id}"

engine = _step_engine()
call_id = profile.harness_class.mint_run_id(None)
call_id = config.harness_class.mint_run_id(None)

# Registered for provisioning through execution — the subscription's
# rotation defers around it. A key never rotates.
Expand All @@ -299,14 +299,14 @@ async def _run(
)
async with gate:
await set_run_phase("provisioning_vm")
host_id = await workflow._lease_host(profile)
host_id = await workflow._lease_host(config)

# Record the call RUNNING once it has a host to run on (its id names
# the on-disk transcript dir) so the live step shows while the agent
# works, then finish it — or fail it if the run raised after
# starting. A provisioning failure happens before this and records
# no call.
async with _runner(workflow, host_id, workflow_id, self.id, profile) as runner:
async with _runner(workflow, host_id, workflow_id, self.id, config) as runner:
context = await runner.prepare_context(context, agent_call_id=call_id)
# Templates read the live workflow + the workspace the agent runs in,
# alongside whatever the workflow's get_prompt_context composes.
Expand All @@ -328,7 +328,7 @@ async def _run(
try:
result = await runner.run_agent(
account_id=workflow.account_id,
profile=profile,
config=config,
agent=self.id,
prompt=prompt,
schema=contract.model_json_schema(),
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/api/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from druks.database import db_session
from druks.durable.enums import OPEN_STATES, RunState
from druks.durable.models import Artifact, Run
from druks.user_settings.models import SettingsProfile
from druks.settings import load_settings

PAGE_SIZE = 200
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
Expand Down Expand Up @@ -59,7 +59,7 @@ async def list_current_work(response: Response) -> DashboardWork:
async def list_current_schedules(response: Response) -> DashboardSchedules:
"""Configured cadence, not scheduler health."""
response.headers["Cache-Control"] = "no-store"
timezone = (await SettingsProfile.get()).timezone
timezone = load_settings().timezone
return DashboardSchedules(
rows=[
DashboardSchedule(
Expand Down
6 changes: 3 additions & 3 deletions backend/druks/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from druks.durable.exceptions import AgentCallNotFound
from druks.events.routes import router as events_router
from druks.files.routes import router as files_router
from druks.harnesses.exceptions import CatalogError, ProfileSettingsError
from druks.harnesses.exceptions import AgentConfigError, CatalogError
from druks.harnesses.routes import router as providers_router
from druks.mcp.catalog import load_mcp_catalog
from druks.mcp.gateway import exceptions as gate_errors
Expand Down Expand Up @@ -220,8 +220,8 @@ async def _catalog_error_handler(request: Request, exc: CatalogError) -> JSONRes
return JSONResponse(status_code=503, content={"error": "HTTP_503", "detail": detail})


@app.exception_handler(ProfileSettingsError)
async def _profile_settings_handler(request: Request, exc: ProfileSettingsError) -> JSONResponse:
@app.exception_handler(AgentConfigError)
async def _profile_settings_handler(request: Request, exc: AgentConfigError) -> JSONResponse:
return JSONResponse(status_code=422, content={"error": "HTTP_422", "detail": str(exc)})


Expand Down
8 changes: 8 additions & 0 deletions backend/druks/core/utils/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ def ensure_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)


def validate_timezone(value: str) -> str:
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise ValueError(f"Unknown IANA timezone: {value!r}") from exc
return value


def operator_local_day(timezone_name: str, now: datetime) -> tuple[ZoneInfo, datetime]:
try:
timezone = ZoneInfo(timezone_name)
Expand Down
6 changes: 4 additions & 2 deletions backend/druks/durable/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from druks.database import create_async_engine_from_url, db_session, get_session, session_scope
from druks.durable.dbos_state import DBOS_SYSTEM_SCHEMA
from druks.settings import load_settings
from druks.user_settings.models import SettingsProfile
from druks.user_settings.models import InstallationSettings

if TYPE_CHECKING:
from druks.workflows import Workflow
Expand Down Expand Up @@ -81,7 +81,7 @@ async def apply_schedules() -> None:
await DBOS.delete_schedule_async(existing["schedule_name"])
# Evaluate in the installation timezone so daily cadence follows DST.
# Personal display preferences must not move a shared schedule.
timezone = (await SettingsProfile.get()).timezone
timezone = load_settings().timezone
for cls, fn in _scheduled:
await DBOS.delete_schedule_async(cls.kind)
cron = await cls.get_schedule()
Expand All @@ -96,6 +96,8 @@ async def launch() -> None:
# loop and async steps share it.
DBOS.launch()
async with session_scope(_step_engine()):
# Commit the singleton before concurrent settings requests can create it.
await InstallationSettings.get()
await apply_schedules()


Expand Down
2 changes: 1 addition & 1 deletion backend/druks/harnesses/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def get_secrets(cls, provider: str, key: str) -> dict[str, Secret]:
refuses: no raw key enters a box."""
if is_registered(provider):
return {provider: get_provider(provider).get_secret(key)}
raise exceptions.ProfileSettingsError(
raise exceptions.AgentConfigError(
f"Druks has no proven API-key transport for provider {provider!r}. "
"Use an Anthropic or OpenAI key."
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@
from druks.secrets.datastructures import Audience
from druks.secrets.enums import SecretKind
from druks.secrets.models import VaultSecret
from druks.user_settings.models import SettingsOverride, SettingsProfile
from druks.user_settings.models import InstallationSettings, SettingsOverride

from .base import Harness
from .exceptions import HarnessNotConnectedError, ProfileSettingsError
from .exceptions import AgentConfigError, HarnessNotConnectedError
from .models import ProviderCatalog
from .providers import get_provider, is_registered, provider_label
from .registry import get_harness


@dataclass(frozen=True)
class Profile:
"""How an agent runs for one account: harness, model, effort, billing, read from
Settings → Agents at call time."""
class AgentConfig:
"""Shared execution settings with the account's selected credential."""

harness_class: type[Harness]
model: str
Expand All @@ -47,7 +46,7 @@ def model_id(self) -> str:

@property
def secrets_id(self) -> str:
"""What a box created for this profile holds: the pasted key, or the
"""What a box created for this config holds: the pasted key, or the
subscriptions it fetches."""
if self.secrets:
return f"{self.api_key.audience_name}.{self.api_key.updated_at:%Y%m%dT%H%M%S}"
Expand All @@ -58,35 +57,33 @@ def charged_account_id(self) -> str | None:
return self.subscription.account_id if self.subscription else None


async def check_profile(harness_name: str, model: str, billing: str) -> type[Harness]:
async def check_config(harness_name: str, model: str, billing: str) -> type[Harness]:
"""The harness that runs the triple; a triple no harness runs raises."""
harness = get_harness(harness_name)
if not harness:
raise ProfileSettingsError(f"no installed harness is named {harness_name!r}.")
raise AgentConfigError(f"no installed harness is named {harness_name!r}.")
provider_id = model.partition("/")[0]
if is_registered(provider_id):
provider = get_provider(provider_id)
if not harness.has_provider(provider):
raise ProfileSettingsError(f"{harness_name} does not run {provider.label} models.")
raise AgentConfigError(f"{harness_name} does not run {provider.label} models.")
else:
catalog = await ProviderCatalog.get(provider_id)
if not catalog:
raise ProfileSettingsError(
raise AgentConfigError(
f"model {model!r} names no provider; add one in Settings → Providers."
)
if harness.provider:
raise ProfileSettingsError(f"{harness_name} does not run {catalog.label} models.")
raise AgentConfigError(f"{harness_name} does not run {catalog.label} models.")
if model not in {entry["id"] for entry in catalog.models}:
raise ProfileSettingsError(f"{catalog.label} lists no model {model!r}.")
raise AgentConfigError(f"{catalog.label} lists no model {model!r}.")
if billing not in harness.billing_options:
raise ProfileSettingsError(
f"{harness_name} runs on an API key only; set billing to api_key."
)
raise AgentConfigError(f"{harness_name} runs on an API key only; set billing to api_key.")
return harness


async def get_profile(agent_name: str, account_id: str | None) -> Profile:
"""Resolve an agent's profile for the supplied or default account.
async def get_config(agent_name: str, account_id: str | None) -> AgentConfig:
"""Resolve shared execution settings and the supplied or default account's credential.
A missing credential raises."""
from druks.apps.registry import agents # cycle: apps → agents → this module

Expand All @@ -96,11 +93,11 @@ async def get_profile(agent_name: str, account_id: str | None) -> Profile:
if not account_id:
account = await Account.get_default()
account_id = account.id if account else None
settings = await SettingsProfile.get(account_id)
settings = await InstallationSettings.get()
harness_name = (await SettingsOverride.agent_harness(agent_name, settings=settings)).value
model = (await SettingsOverride.agent_model(agent_name, settings=settings)).value
billing = (await SettingsOverride.agent_billing(agent_name, settings=settings)).value
harness_class = await check_profile(harness_name, model, billing)
harness_class = await check_config(harness_name, model, billing)
provider_id = model.partition("/")[0]
subscription = None
provider_key = None
Expand All @@ -121,7 +118,7 @@ async def get_profile(agent_name: str, account_id: str | None) -> Profile:
timeout = (
await SettingsOverride.agent_timeout(agent_name, agent.timeout, settings=settings)
).value
return Profile(
return AgentConfig(
harness_class=harness_class,
model=model,
subscription=subscription,
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/harnesses/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,5 @@ def __init__(self, tag: str) -> None:
self.tag = tag


class ProfileSettingsError(HarnessError):
class AgentConfigError(HarnessError):
"""A (harness, model, billing) triple no installed harness runs; a 422."""
Loading