Files
AgentLab/backend/app/api/settings.py
T
ThearchyHelios 9727c21d8d 可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管
一、结论句裁判

- 报告节点新增 claims: judge:写完、核对完之后,把结论句和它引用的证据摘录交给裁判模型,
  判为支持 / 部分支持 / 不支持 / 不是结论,附一句理由;判定只标注,不改写正文
- 送裁判的句子按固定规则预筛:带引用的、含方向词或因果词的送去判,短句、过渡句不送
- 证据摘录:指标写名称、值和原式;查询给 SQL 前 300 字和被引用的行,被引用的列一定给到;遮罩列一律不给;
  规则里写明「数字已经由系统核对过,不要复算」
- 一份报告合并成一次调用,超出句数就分批;结构化输出失败时退回 JSON 解析,再失败记为未裁判
- 裁判模型的优先级:节点上写的 > 设置里的「证据裁判模型」> Copilot 的模型;思考关闭;
  和写作模型相同时给警告(等于自己审自己)
- rewrite_once(默认关):把不支持的句子和理由交回写作者只改这几句,再裁一次;
  改写稿冒出原稿没有的问题就不采用,保留原稿和原来的判定

二、成本上限

- 每份报告的句数、金额、时长,探索运行每次点击的金额,全局每日金额,每一项都可以自定义,也都可以设成不限;
  默认 40 句、$0.05、30 秒,每次点击 $0.01,每天 $2
- 调用前先估算,超出上限按优先级截断:先判带引用的,再判含方向词、因果词的,其余记为未裁判并写明「已到上限」;
  不报错,也不让报告失败
- 每日花费按本地日期计数,持久化在设置表里;重跑同一次运行不重复计费;价格目录里没有的模型只计次数,并写明估不出金额

三、正式运行与探索运行

- 正式运行在报告节点内裁判,判定写进报告文档、随文档封存;不支持的按 on_unsupported(degrade / withhold)降档或不予出具,
  裁判没跑成记缺口,不能判完整出具
- 探索运行按需裁判:POST /api/runs/{id}/evidence/judge,点开哪句判哪句;判定作为 evidence.judged 事件追加在封存之后,
  标「封存后追加」,封存核对仍然一致;只读封存范围内的工件;同一句不重复判;运行在裁判期间被接着跑时不记录
- 正式运行调这个接口返回 409,写明「正式运行的裁判在节点内完成」

四、治理与自动修复

- G3 放开 judge:受管级别写 judge 必须写预算(judge.max_cost_usd,或显式写 null 表示不限)
- 发布前自动修复:缺预算时给选项(默认金额 / 不限),不替人选;
  从 require_citation 改成 judge 不算降低要求,judge 改回 off、withhold 放宽成 degrade 算降低要求
- Copilot 手册:受管模板可以写 judge 并带预算;问数据按需裁判,不替人打开结论句策略

五、界面

- 结论句句末放徽标:支持 ◆、部分支持 ◇、不支持 ! 整句浅底、未裁判 ?;不用表示「已核对」的绿色;n / N 也跳到不支持的句子
- 证据面板里「模型的解释」:带「模型判断 · 模型名 · 非确定」,封存后追加的另外标出;
  「请模型判断这句」只在探索运行、还没判过时出现,触顶时写明已到上限和怎么调
- 出具横幅多一段结论句计数;设置页新增「证据裁判」一组(裁判模型、各项上限、今日花费),设成不限时写明费用受什么约束;
  报告节点可以配 judge;运行轨迹里裁判相关的事件都有中文标题

六、门禁修正

- 引用模式下,图里没有口径卡(只引查询单元格)的,出具契约不再要求 metrics_from,受管门禁也不再要求 required:
  strict 加 cells 已经保证每个数都点得开、对不上就不予出具;有口径卡的照旧要求。
  以前问数据的图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
2026-09-29 11:10:53 +08:00

558 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import os
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import health
from app.core.errors import explain, not_configured, raw
from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
from app.db.base import get_session
from app.db.models import Provider, Setting
from app.engine.judge import JUDGE_DEFAULTS, SETTING_GROUP as JUDGE_GROUP, SPEND_KEY as JUDGE_SPEND
from app.engine.judge import daily_spend, judge_settings
from app.engine.judge import sanitize_settings as judge_values, settings_problem as judge_problem
from app.providers import catalog
from app.providers.factory import ModelSpec, ProviderNotConfigured, _resolve_key, build_chat_model
router = APIRouter(prefix="/api", tags=["settings"])
# --------------------------------------------------------------------------
# Provider
# --------------------------------------------------------------------------
class ProviderIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: str = "anthropic"
base_url: str | None = None
api_key: str | None = None
models: list[dict[str, Any]] = Field(default_factory=list)
default_model: str | None = None
enabled: bool = True
extra: dict[str, Any] = Field(default_factory=dict)
class ProviderPatch(BaseModel):
name: str | None = None
base_url: str | None = None
api_key: str | None = None # 传空字符串表示清空;不传表示不动
models: list[dict[str, Any]] | None = None
default_model: str | None = None
enabled: bool | None = None
extra: dict[str, Any] | None = None
class ProviderOut(BaseModel):
id: str
name: str
kind: str
base_url: str | None
default_model: str | None
models: list[dict[str, Any]]
enabled: bool
extra: dict[str, Any]
# 只回掩码,明文 key 永远不出后端
api_key_masked: str = ""
has_key: bool = False
#: 最近一次测连接(见 app/api/health.py)。没测过、或者连接配置改过之后都是 None
last_checked_at: str | None = None
last_check_ok: bool | None = None
last_latency_ms: int | None = None
last_error: str | None = None
#: extra 里这一项是后端记的最近一次测连接结果,不是用户填的配置:不回显、
#: 不接受客户端写入,保存 extra 时原样留着
LAST_CHECK = "last_check"
def _user_extra(extra: dict[str, Any] | None) -> dict[str, Any]:
return {k: v for k, v in (extra or {}).items() if k != LAST_CHECK}
def _with_saved_headers(extra: dict[str, Any], saved: dict[str, Any] | None) -> dict[str, Any]:
"""请求头的值回显的是 ***:原样带回来的,换回存着的真值。
设置页保存时会把读到的 extra 整块带回来。以前照单全收,于是改个名字、
勾一下启用,自定义请求头就全变成了字面上的 ***,接口从此 401。
"""
headers = extra.get("headers")
if not isinstance(headers, dict):
return extra
old = (saved or {}).get("headers") or {}
return {**extra, "headers": {k: (old.get(k, v) if v == "***" else v) for k, v in headers.items()}}
def _connection(provider: Provider, model: str | None = None) -> str:
"""连接配置的指纹:类型、地址、实际用的钥匙、请求头这些、测的哪个模型。"""
return health.fingerprint(
"provider", provider.kind, (provider.base_url or "").strip(), _resolve_key(provider) or "",
_user_extra(provider.extra), model or provider.default_model or "",
)
def _set_last_check(row: Provider, last: dict[str, Any] | None) -> None:
row.extra = {**_user_extra(row.extra), **({LAST_CHECK: last} if last else {})}
def _to_out(row: Provider) -> ProviderOut:
extra = _user_extra(row.extra)
return ProviderOut(
id=row.id,
name=row.name,
kind=row.kind,
base_url=row.base_url,
default_model=row.default_model,
models=row.models or [],
enabled=row.enabled,
extra={k: v for k, v in extra.items() if k != "headers"} | (
{"headers": {k: "***" for k in extra.get("headers", {})}}
if extra.get("headers")
else {}
),
api_key_masked=mask(row.api_key),
has_key=bool(row.api_key),
**health.fields((row.extra or {}).get(LAST_CHECK)),
)
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
rows = (await session.execute(select(Provider).order_by(Provider.created_at))).scalars()
return [_to_out(r) for r in rows]
@router.get("/providers/catalog")
async def provider_catalog() -> dict[str, Any]:
"""内置的 provider 预设和模型目录,前端用它渲染"添加 Provider"表单。"""
return {
"kinds": catalog.PROVIDER_KINDS,
"env_detected": {
"ANTHROPIC_API_KEY": bool(os.environ.get("ANTHROPIC_API_KEY")),
"ANTHROPIC_AUTH_TOKEN": bool(os.environ.get("ANTHROPIC_AUTH_TOKEN")),
"ANTHROPIC_BASE_URL": os.environ.get("ANTHROPIC_BASE_URL") or None,
"OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY")),
"TAVILY_API_KEY": bool(os.environ.get("TAVILY_API_KEY")),
},
}
@router.post("/providers", response_model=ProviderOut, status_code=201)
async def create_provider(
payload: ProviderIn, session: AsyncSession = Depends(get_session)
) -> ProviderOut:
problem = _config_problem(payload.kind, payload.base_url)
if problem:
raise HTTPException(400, problem)
exists = (
await session.execute(select(Provider).where(Provider.name == payload.name))
).scalar_one_or_none()
if exists:
raise HTTPException(409, f"已经有叫「{payload.name}」的模型接入了,换个名字")
row = Provider(
name=payload.name,
kind=payload.kind,
base_url=payload.base_url or None,
api_key=encrypt(payload.api_key),
models=payload.models,
default_model=payload.default_model,
enabled=payload.enabled,
extra=_user_extra(payload.extra),
)
# 表单里刚测过这份配置:保存下来就带着那次结果
_set_last_check(row, health.draft_result(_connection(row)))
session.add(row)
await session.commit()
await session.refresh(row)
return _to_out(row)
@router.patch("/providers/{provider_id}", response_model=ProviderOut)
async def update_provider(
provider_id: str, payload: ProviderPatch, session: AsyncSession = Depends(get_session)
) -> ProviderOut:
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "这个模型接入不存在,可能已经被删了")
data = payload.model_dump(exclude_unset=True)
before, last = _connection(row), (row.extra or {}).get(LAST_CHECK)
if "api_key" in data:
row.api_key = encrypt(data.pop("api_key")) if data["api_key"] else None
data.pop("api_key", None)
if data.get("extra") is not None:
data["extra"] = _with_saved_headers(_user_extra(data["extra"]), row.extra)
else:
data.pop("extra", None)
for key, value in data.items():
setattr(row, key, value)
after = _connection(row)
if after != before:
# 测过的已经不是这份配置了:旧结果作废,表单里测过这一份的话换成那一次
last = health.draft_result(after)
_set_last_check(row, last)
await session.commit()
await session.refresh(row)
return _to_out(row)
@router.delete("/providers/{provider_id}", status_code=204)
async def delete_provider(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> None:
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "这个模型接入不存在,可能已经被删了")
await session.delete(row)
await session.commit()
class TestIn(BaseModel):
model: str | None = None
prompt: str = "用一句话介绍你自己"
def _config_problem(kind: str, base_url: str | None) -> str | None:
"""保存或测试前就能看出来的配置错误。"""
if kind not in {k["kind"] for k in catalog.PROVIDER_KINDS}:
return f"不认识「{kind}」这种接入类型"
if kind == "openai_compatible" and not (base_url or "").strip():
return "「OpenAI 兼容」必须填 Base URL,例如 https://api.deepseek.com/v1"
return None
#: 测试连接最多等多久。真正跑节点用的是 model_timeout_seconds(默认 5 分钟),
#: 弹窗里转 5 分钟等于没有反馈
_TEST_TIMEOUT = 30
async def _try_model(provider: Provider, model: str | None, prompt: str) -> dict[str, Any]:
"""真发一次请求。失败也是正常结果,照实返回原因,不抛。"""
import asyncio
import time
from app.engine.state import message_text
model = model or provider.default_model
if not model:
return {"ok": False, "error": "还没有可测的模型",
"hint": "先在「可选模型」里加一个,或者填上默认模型", "detail": ""}
try:
chat = build_chat_model(provider, ModelSpec(model=model, max_tokens=256,
timeout=_TEST_TIMEOUT))
except ProviderNotConfigured as e:
if "API Key" in str(e):
return {"ok": False, "error": "还没有填 API Key",
"hint": "填上 Key 再测;也可以在后端的环境变量里配", "detail": str(e)}
return {"ok": False, "error": not_configured(e), "hint": "", "detail": str(e)}
started = time.perf_counter()
try:
async with asyncio.timeout(_TEST_TIMEOUT):
reply = await chat.ainvoke(prompt)
except Exception as e: # noqa: BLE001
reason, hint = explain(e)
return {"ok": False, "error": f"测试没通过:{reason}", "hint": hint, "detail": raw(e),
"model": model}
usage = getattr(reply, "usage_metadata", None) or {}
return {
"ok": True,
"latency_ms": int((time.perf_counter() - started) * 1000),
"model": model,
"reply": message_text(reply)[:500],
"usage": usage,
}
@router.post("/providers/{provider_id}/test")
async def test_provider(
provider_id: str, payload: TestIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""真发一次请求验证配置。设置页的"测试连接"按钮调它。"""
row = await session.get(Provider, provider_id)
if not row:
raise HTTPException(404, "这个模型接入不存在,可能已经被删了")
result = await _try_model(row, payload.model, payload.prompt)
# 只记默认模型的结果:拿别的模型测出 invalid_model,不等于这个接入连不上
if (payload.model or row.default_model) == row.default_model:
_set_last_check(row, _test_record(result))
await session.commit()
return result
def _test_record(result: dict[str, Any]) -> dict[str, Any]:
return health.record(result.get("ok"), result.get("latency_ms"), result.get("error"))
class ProviderDraftIn(BaseModel):
"""一份还没保存(或改了还没保存)的接入配置。带 id 表示在编辑已有的那个。"""
id: str | None = None
name: str = "草稿"
kind: str = "anthropic"
base_url: str | None = None
api_key: str | None = None # 编辑时留空=沿用已保存的
models: list[dict[str, Any]] = Field(default_factory=list)
default_model: str | None = None
extra: dict[str, Any] = Field(default_factory=dict)
#: 这次测哪个模型,不填用 default_model
model: str | None = None
prompt: str = "用一句话介绍你自己"
def _draft_provider(payload: ProviderDraftIn, saved: Provider | None) -> Provider:
"""拼一个不进会话的临时接入点,只拿来试一下。
编辑时 Key 框留空表示不改、请求头回显的是 ***——测试都得换回存着的真值,
否则改个 Base URL 想测一下,只会得到一句"密钥不对"。
"""
extra = _user_extra(payload.extra)
if saved:
extra = _with_saved_headers(extra, saved.extra)
return Provider(
name=payload.name or (saved.name if saved else "草稿"),
kind=payload.kind,
base_url=(payload.base_url or "").strip() or None,
api_key=encrypt(payload.api_key) if payload.api_key else (saved.api_key if saved else None),
models=payload.models,
default_model=payload.default_model,
enabled=True,
extra=extra,
)
async def _draft(payload: ProviderDraftIn, session: AsyncSession) -> Provider:
saved = await session.get(Provider, payload.id) if payload.id else None
return _draft_provider(payload, saved)
@router.post("/providers/test")
@router.post("/settings/providers/test", include_in_schema=False)
async def test_provider_draft(
payload: ProviderDraftIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""测一份没保存的配置,不落库。
以前只能测已保存的:Base URL 填错要保存→关弹窗→点测试→看提示→再打开改。
接内网网关这种填错一个字就连不上的东西,来回四五趟。
"""
problem = _config_problem(payload.kind, payload.base_url)
if problem:
return {"ok": False, "error": problem, "hint": "", "detail": ""}
saved = await session.get(Provider, payload.id) if payload.id else None
draft = _draft_provider(payload, saved)
result = await _try_model(draft, payload.model, payload.prompt)
last, key = _test_record(result), _connection(draft, payload.model)
health.remember_draft(key, last)
if saved is not None and key == _connection(saved):
# 编辑框里没改连接、只是再测一次:测的就是已保存的那份
_set_last_check(saved, last)
await session.commit()
return result
@router.post("/providers/models")
async def probe_provider_models(
payload: ProviderDraftIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""问这个接入点有哪些模型,省得手敲模型 id。
知识库那边的探测不带鉴权,接需要 Key 的网关时必然 401,所以这里单独做一份:
Key、自定义请求头都按真正调用时的方式带上。
"""
import httpx
problem = _config_problem(payload.kind, payload.base_url)
if problem:
return {"ok": False, "error": problem, "hint": "", "detail": "", "models": []}
provider = await _draft(payload, session)
if provider.kind == "mock":
return {"ok": True, "models": [m["id"] for m in catalog.MOCK_MODELS], "url": ""}
extra = dict(provider.extra or {})
# 和真正调用时取同一把钥匙:只有官方 OpenAI / Anthropic 才退回环境变量。
# 兼容网关是别人家的服务,Key 框空着就不带——否则点一下「拉取模型」,
# 环境里那把 OpenAI 的钥匙就发给了表单上随便填的一个地址
key = _resolve_key(provider) or ""
headers: dict[str, str] = {}
if provider.kind == "anthropic":
base = provider.base_url or os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com"
url = base.rstrip("/") + "/v1/models"
headers["anthropic-version"] = "2023-06-01"
if key:
if extra.get("auth_style") == "bearer":
headers["Authorization"] = f"Bearer {key}"
else:
headers["x-api-key"] = key
else:
url = (provider.base_url or "https://api.openai.com/v1").rstrip("/") + "/models"
if key:
headers["Authorization"] = f"Bearer {key}"
headers.update({k: str(v) for k, v in (extra.get("headers") or {}).items()})
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
except Exception as e: # noqa: BLE001
reason, hint = explain(e)
return {"ok": False, "error": f"没拿到模型列表:{reason}", "hint": hint,
"detail": raw(e), "models": [], "url": url}
models = [m.get("id", "") for m in (data.get("data") or []) if isinstance(m, dict) and m.get("id")]
return {"ok": True, "models": models, "url": url}
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
DEFAULT_SETTINGS: dict[str, Any] = {
"ui": {"theme": "system", "language": "zh", "canvas_snap": True, "autosave": True},
"run": {
"default_memory_scope": "default",
"default_collection": "default",
"stream_tokens": True,
"confirm_dangerous_tools": True,
# 「始终允许 · 门控把关」的工具每次调用前问的那个模型(tools/trust.py)。
# 空着就用默认 provider 的默认模型
"tool_gate_provider": None,
"tool_gate_model": None,
# agent 护栏(engine/guards.py):兜底步数、每个节点的令牌 / 金额预算。预算为 null 表示不限
"agent_max_steps": 100,
"agent_budget_tokens": 2_000_000,
"agent_budget_usd": None,
},
"limits": {
"max_graph_steps": app_settings.max_graph_steps,
"max_agent_steps": app_settings.max_agent_steps,
"max_run_seconds": app_settings.max_run_seconds,
},
# 结论句裁判(engine/judge.py):证据裁判模型和各项上限,上限存 null 表示不限。不放进 limits:
# 那一组是环境变量定的、只读,这一组是用户自己定的
JUDGE_GROUP: dict(JUDGE_DEFAULTS),
}
async def run_defaults(session: AsyncSession) -> dict[str, Any]:
"""设置里 run 这一组,没存过的键补内置默认。
发起运行和引擎都从这里读。各拼各的默认值,两边迟早会对「没存过」理解得
不一样——以前这三项就是只存不读,设置页上改了等于没改。
"""
row = await session.get(Setting, "run")
return {**DEFAULT_SETTINGS["run"], **((row.value if row else None) or {})}
def tool_approval_default(run: dict[str, Any]) -> str:
"""「危险工具默认需要人工确认」换算成节点 approval 的取值,节点自己配了的不看它。
只有明确存了 False 才关:值缺了、存坏了都按开着算。关掉审批得是一次明确的
选择,不能是某个字段没写上的副作用。
"""
return "never" if run.get("confirm_dangerous_tools") is False else "dangerous"
async def resolve_run_scope(
session: AsyncSession, memory_scope: str | None, collection: str | None
) -> tuple[str, str]:
"""请求没带的记忆域和知识库,取设置里的运行默认值。
空串等于没填:设置页把输入框清空再保存,存下来的就是空串,拿它当记忆域
写进去的东西谁也读不回来。
"""
defaults = await run_defaults(session)
def pick(explicit: str | None, key: str) -> str:
for value in (explicit, defaults.get(key)):
if isinstance(value, str) and value.strip():
return value.strip()
return "default"
return pick(memory_scope, "default_memory_scope"), pick(collection, "default_collection")
@router.get("/settings")
async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = (await session.execute(select(Setting))).scalars()
stored = {r.key: r.value for r in rows}
# limits 是服务端配置(环境变量),只读:库里可能存着旧版设置页写进去的一份
#(见过 max_agent_steps: 25),它从来不生效,却会盖住真实的上限,界面照着错的数许诺
stored.pop("limits", None)
# 裁判的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零
stored.pop(JUDGE_SPEND, None)
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
# 裁判设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
for key, value in stored.items():
merged.setdefault(key, value)
return merged
class SettingsIn(BaseModel):
values: dict[str, Any]
@router.put("/settings")
async def put_settings(
payload: SettingsIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
from app.tools.trust import SETTING_KEY as TRUST_KEY
if JUDGE_GROUP in payload.values and (problem := judge_problem(payload.values[JUDGE_GROUP])):
# 先查后写:一组里有一项不对就整个请求不落库,免得存下半组
raise HTTPException(422, problem)
for key, value in payload.values.items():
if key in (TRUST_KEY, "limits", JUDGE_SPEND):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的,写进库也不生效。
# 裁判的每日计数是引擎记的账,只有裁判自己写
continue
row = await session.get(Setting, key)
if row:
row.value = value
else:
session.add(Setting(key=key, value=value))
await session.commit()
return await get_settings(session)
@router.get("/settings/judge/spend")
async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)结论句裁判花了多少,设置页放在每日上限旁边。只读:计数只有裁判自己记。
{date, usd, calls, unpriced_calls, daily_max_usd}。unpriced_calls 是价格目录里没有的模型的调用次数,
这些调用估不出金额、没有算进 usd。daily_max_usd 为 null 表示不限。
"""
return {**await daily_spend(), "daily_max_usd": (await judge_settings(session))["daily_max_usd"]}
@router.get("/system")
async def system_info() -> dict[str, Any]:
"""给设置页顶部展示的运行环境概览。"""
import sys
from app.sandbox.manager import sandbox_manager
return {
"python": sys.version.split()[0],
"data_dir": str(app_settings.data_dir),
"sandbox": await sandbox_manager.health(),
"limits": {
"max_graph_steps": app_settings.max_graph_steps,
"max_agent_steps": app_settings.max_agent_steps,
"max_concurrent_runs": app_settings.max_concurrent_runs,
"max_run_seconds": app_settings.max_run_seconds,
"model_timeout_seconds": app_settings.model_timeout_seconds,
},
}