Skip to content

Commit 19c4a7d

Browse files
authored
Improve agent planning and observability (#380)
* Improve agent planning and observability * Stabilize account provisioning planner route * Harden structured agent planner boundaries
1 parent c3bcbf3 commit 19c4a7d

23 files changed

Lines changed: 1343 additions & 166 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ LANGFUSE_BASE_URL=
168168
# Docker-network Bifrost URL http://bifrost:8080/openai is allowed.
169169
AGENT_PLANNER_MODEL=accounts/fireworks/models/kimi-k2p6
170170
AGENT_FALLBACK_MODEL=gpt-4.1-mini
171+
# Keep this below AGENT_API_TIMEOUT_SECONDS so deterministic fallback can respond.
172+
AGENT_STRUCTURED_PLANNER_ENABLED=true
173+
AGENT_STRUCTURED_PLANNER_TIMEOUT_SECONDS=6.0
171174
AGENT_INTENT_NORMALIZER_ENABLED=true
172175
AGENT_INTENT_NORMALIZER_TIMEOUT_SECONDS=3.0
173176
AGENT_FAST_MODEL=

ENVIRONMENT.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,12 @@ Pydantic import errors.
169169
- `Optional`: `AGENT_REASONING_MODEL`, `AGENT_REASONING_BASE_URL`, `AGENT_REASONING_API_KEY`
170170
- `Optional`: `AGENT_PLANNER_MODEL` (default: `accounts/fireworks/models/kimi-k2p6`)
171171
- `Optional`: `AGENT_FALLBACK_MODEL` (default: `gpt-4.1-mini`; uses `OPENAI_API_KEY` / `OPENAI_BASE_URL`)
172-
- `Optional`: `AGENT_INTENT_NORMALIZER_ENABLED` (default: `true`; when deterministic parsing fails, asks the fast agent model to rewrite loose phrasing into one supported command shape)
173-
- `Optional`: `AGENT_INTENT_NORMALIZER_TIMEOUT_SECONDS` (default: `3.0`)
172+
- `Optional`: `AGENT_STRUCTURED_PLANNER_ENABLED` (default: `true`; enables the production proposal-only structured planner when a model provider is configured)
173+
- `Optional`: `AGENT_INTENT_NORMALIZER_ENABLED` (default: `true`; enables the legacy rewrite fallback for unsupported phrasing)
174+
- `Optional`: `AGENT_INTENT_NORMALIZER_TIMEOUT_SECONDS` (default: `3.0`; timeout for legacy normalization calls)
175+
- `Optional`: `AGENT_STRUCTURED_PLANNER_TIMEOUT_SECONDS` (default: `6.0`; timeout for the production structured planner; keep it below `AGENT_API_TIMEOUT_SECONDS` so deterministic fallback can respond)
174176
- Note: tier-specific agent models can point at OpenAI-compatible providers such as Bifrost or Fireworks. Agent model base URLs must be HTTPS endpoints on `bifrost.508.dev`, `api.openai.com`, `api.fireworks.ai`, or `openrouter.ai`, except the internal Docker-network Bifrost URL `http://bifrost:8080/openai` is also allowed for same-host deployments. If `OPENAI_BASE_URL` points at Bifrost and tier-specific `AGENT_*` values are unset, the planner defaults to Fireworks Kimi via Bifrost as `fireworks/accounts/fireworks/models/kimi-k2p6`. Explicit Bifrost provider-prefixed planner models, such as `openrouter/openai/gpt-4.1-mini`, are passed through unchanged. If Bifrost is not configured and `FIREWORKS_API_KEY` is set, the planner falls back to direct Fireworks as `accounts/fireworks/models/kimi-k2p6`. If a configured provider is missing its usable API key, it is skipped and the fallback order is `reasoning -> strong -> fast -> AGENT_FALLBACK_MODEL -> gpt-4.1-mini`; `strong` falls back through `fast`, and `fast` falls back through the OpenAI fallback.
175-
- Agent tools follow the deterministic path: deterministic parsing runs first, the optional LLM intent normalizer can only rewrite unsupported phrasing into supported command shapes, policy authorizes scopes, write tools require confirmation, and the backend executes known-good tool code.
177+
- Agent tools follow a proposal-and-policy path: when configured, the structured planner drafts a bounded typed tool plan using the selected fast or strong tier; every drafted action is allowlisted and shape-validated before deterministic policy authorizes it. Write tools require confirmation and the backend executes only known-good tool code. Provider failures fall back to deterministic parsing, then legacy normalization for unsupported phrasing.
176178
- `Optional`: `GITHUB_API_TOKEN`, `GITHUB_DEFAULT_REPO`, `GITHUB_ALLOWED_REPOS` (comma-separated; GitHub Issues are the canonical code-task backend for agent-created code work, and agent tools only access the default/allowed repositories).
177179
- Existing integration tools also expose CRM contact search/update, DocuSeal member-agreement submission, and Migadu mailbox creation when their normal service env vars are configured.
178180
- Note: the current generic task tool registry is process-local and non-durable for non-code/org tasks until the task-management platform is selected.

apps/admin_dashboard/src/main.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,9 @@ type AgentReport = {
416416
status_counts?: Record<string, number>
417417
intent_counts?: Record<string, number>
418418
planner_counts?: Record<string, number>
419+
model_counts?: Record<string, number>
420+
action_counts?: Record<string, number>
421+
tool_outcome_counts?: Record<string, number>
419422
recent_unsupported?: Array<{
420423
occurred_at?: string
421424
actor?: string
@@ -7873,6 +7876,9 @@ function AgentView({
78737876
["Status", report?.status_counts || {}],
78747877
["Intent", report?.intent_counts || {}],
78757878
["Planner", report?.planner_counts || {}],
7879+
["Model", report?.model_counts || {}],
7880+
["Action", report?.action_counts || {}],
7881+
["Tool outcome", report?.tool_outcome_counts || {}],
78767882
] as const
78777883
)
78787884
.flatMap(([label, counts]) =>

apps/api/src/five08/backend/api.py

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
AgentRequest,
5050
AgentResponse,
5151
InMemoryTaskStore,
52+
OpenAICompatibleAgentPlanner,
5253
OpenAICompatibleIntentNormalizer,
5354
PolicyEngine,
5455
ToolRegistry,
@@ -251,9 +252,7 @@ class JobsQueryFilters:
251252
_JOB_FUNCTIONS = JOB_FUNCTIONS
252253
_ONBOARDING_STATUS_FIELD = "cOnboardingState"
253254
_ONBOARDER_FIELD = "cOnboarder"
254-
_GENERIC_UNSUPPORTED_AGENT_MESSAGE = (
255-
"I could not turn that into a supported task action."
256-
)
255+
_GENERIC_UNSUPPORTED_AGENT_MESSAGE = "I could not map that to a supported workflow."
257256
_SENSITIVE_PAYLOAD_KEY_MARKERS = (
258257
"api_key",
259258
"apikey",
@@ -352,6 +351,7 @@ def _get_agent_orchestrator() -> AgentOrchestrator:
352351
),
353352
),
354353
model_config=AgentModelConfig.from_settings(settings),
354+
planner=OpenAICompatibleAgentPlanner.from_settings(settings),
355355
intent_normalizer=OpenAICompatibleIntentNormalizer.from_settings(
356356
settings
357357
),
@@ -1820,6 +1820,20 @@ def _agent_request_audit_metadata(
18201820
"intent": response.plan.intent if response.plan else None,
18211821
"planner": response.plan.planner if response.plan else None,
18221822
"operation_id": response.plan.operation_id if response.plan else None,
1823+
"model": response.plan.model.model if response.plan else None,
1824+
"model_tier": response.plan.model_tier if response.plan else None,
1825+
"model_source_tier": (
1826+
response.plan.model.source_tier if response.plan else None
1827+
),
1828+
"action_names": (
1829+
[action.tool_name for action in response.plan.actions]
1830+
if response.plan
1831+
else []
1832+
),
1833+
"tool_outcomes": [
1834+
{"tool_name": result.tool_name, "status": result.status}
1835+
for result in response.results
1836+
],
18231837
"context_sources": (
18241838
[source.model_dump(mode="json") for source in response.plan.context_sources]
18251839
if response.plan
@@ -3000,7 +3014,11 @@ def _shape_dashboard_agent_request_report(
30003014
rows: list[dict[str, Any]],
30013015
) -> dict[str, Any]:
30023016
summary = {
3003-
"total": len(rows),
3017+
"total": sum(
3018+
1
3019+
for row in rows
3020+
if str(row.get("action") or "agent.request") == "agent.request"
3021+
),
30043022
"handled": 0,
30053023
"requires_confirmation": 0,
30063024
"needs_clarification": 0,
@@ -3010,20 +3028,39 @@ def _shape_dashboard_agent_request_report(
30103028
status_counts: dict[str, int] = {}
30113029
intent_counts: dict[str, int] = {}
30123030
planner_counts: dict[str, int] = {}
3031+
model_counts: dict[str, int] = {}
3032+
action_counts: dict[str, int] = {}
3033+
tool_outcome_counts: dict[str, int] = {}
30133034
recent_unsupported: list[dict[str, Any]] = []
30143035

30153036
for row in rows:
3037+
event_action = str(row.get("action") or "agent.request")
30163038
metadata = row.get("metadata")
30173039
if not isinstance(metadata, dict):
30183040
metadata = {}
30193041
status = str(metadata.get("status") or "unknown")
30203042
intent = metadata.get("intent") or "unknown"
30213043
planner = metadata.get("planner") or "unknown"
3044+
model = metadata.get("model") or "unknown"
3045+
3046+
if event_action == "agent.request":
3047+
_increment_dashboard_count(status_counts, status)
3048+
_increment_dashboard_count(intent_counts, intent)
3049+
_increment_dashboard_count(planner_counts, planner)
3050+
_increment_dashboard_count(model_counts, model)
3051+
for action_name in metadata.get("action_names") or []:
3052+
_increment_dashboard_count(action_counts, action_name)
3053+
for outcome in metadata.get("tool_outcomes") or []:
3054+
if not isinstance(outcome, dict):
3055+
continue
3056+
tool_name = str(outcome.get("tool_name") or "unknown")
3057+
status_label = str(outcome.get("status") or "unknown")
3058+
_increment_dashboard_count(
3059+
tool_outcome_counts, f"{tool_name}:{status_label}"
3060+
)
30223061

3023-
_increment_dashboard_count(status_counts, status)
3024-
_increment_dashboard_count(intent_counts, intent)
3025-
_increment_dashboard_count(planner_counts, planner)
3026-
3062+
if event_action != "agent.request":
3063+
continue
30273064
if status in {"executed", "requires_confirmation"}:
30283065
summary["handled"] += 1
30293066
if status == "requires_confirmation" or metadata.get("requires_confirmation"):
@@ -3065,30 +3102,55 @@ def _shape_dashboard_agent_request_report(
30653102
"status_counts": status_counts,
30663103
"intent_counts": intent_counts,
30673104
"planner_counts": planner_counts,
3105+
"model_counts": model_counts,
3106+
"action_counts": action_counts,
3107+
"tool_outcome_counts": tool_outcome_counts,
30683108
"recent_unsupported": recent_unsupported,
30693109
}
30703110

30713111

30723112
def _dashboard_agent_request_report(limit: int) -> dict[str, Any]:
30733113
limit = _limit_dashboard_count(limit)
30743114
sql = """
3075-
SELECT
3076-
id::text,
3077-
occurred_at,
3078-
result,
3079-
actor_provider,
3080-
actor_subject,
3081-
actor_display_name,
3082-
correlation_id,
3083-
metadata
3084-
FROM audit_events
3085-
WHERE action = 'agent.request'
3115+
WITH recent_requests AS (
3116+
SELECT
3117+
id::text,
3118+
occurred_at,
3119+
action,
3120+
result,
3121+
actor_provider,
3122+
actor_subject,
3123+
actor_display_name,
3124+
correlation_id,
3125+
metadata
3126+
FROM audit_events
3127+
WHERE action = 'agent.request'
3128+
ORDER BY occurred_at DESC
3129+
LIMIT %s
3130+
), recent_confirmations AS (
3131+
SELECT
3132+
id::text,
3133+
occurred_at,
3134+
action,
3135+
result,
3136+
actor_provider,
3137+
actor_subject,
3138+
actor_display_name,
3139+
correlation_id,
3140+
metadata
3141+
FROM audit_events
3142+
WHERE action = 'agent.confirmation'
3143+
ORDER BY occurred_at DESC
3144+
LIMIT %s
3145+
)
3146+
SELECT * FROM recent_requests
3147+
UNION ALL
3148+
SELECT * FROM recent_confirmations
30863149
ORDER BY occurred_at DESC
3087-
LIMIT %s
30883150
"""
30893151
with get_postgres_connection(settings) as conn:
30903152
with conn.cursor(row_factory=dict_row) as cursor:
3091-
cursor.execute(sql, (limit,))
3153+
cursor.execute(sql, (limit, limit))
30923154
rows = cursor.fetchall()
30933155

30943156
return _shape_dashboard_agent_request_report(rows)
@@ -8845,7 +8907,17 @@ async def agent_confirmation_handler(
88458907
plan=plan,
88468908
metadata={
88478909
"status": response.status,
8910+
"intent": plan.intent,
8911+
"planner": plan.planner,
8912+
"model": plan.model.model,
8913+
"model_tier": plan.model_tier,
8914+
"model_source_tier": plan.model.source_tier,
8915+
"action_names": [action.tool_name for action in plan.actions],
88488916
"results": [result.model_dump(mode="json") for result in results],
8917+
"tool_outcomes": [
8918+
{"tool_name": result.tool_name, "status": result.status}
8919+
for result in results
8920+
],
88498921
},
88508922
)
88518923
return JSONResponse(

apps/api/src/five08/backend/static/dashboard/.vite/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"index.html": {
3-
"file": "assets/index-Ce7YIjfO.js",
3+
"file": "assets/index-DCDV0WK0.js",
44
"name": "index",
55
"src": "index.html",
66
"isEntry": true,

apps/api/src/five08/backend/static/dashboard/assets/index-Ce7YIjfO.js renamed to apps/api/src/five08/backend/static/dashboard/assets/index-DCDV0WK0.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/api/src/five08/backend/static/dashboard/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>508 Operations Dashboard</title>
7-
<script type="module" crossorigin src="/dashboard/assets/index-Ce7YIjfO.js"></script>
7+
<script type="module" crossorigin src="/dashboard/assets/index-DCDV0WK0.js"></script>
88
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-2UypYUrJ.css">
99
</head>
1010
<body>

apps/discord_bot/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,21 @@ Agent command flow:
4848
/agent request or @bot mention
4949
-> bot resolves Discord user/guild/channel/role context
5050
-> POST /agent/requests on the backend API
51-
-> backend parses the request into a typed plan
51+
-> backend uses a structured proposal planner when configured, with deterministic fallback
52+
-> backend validates the typed plan and authorizes every proposed tool
5253
-> deterministic backend policy authorizes each proposed tool
5354
-> read actions execute synchronously
5455
-> write actions return a frozen confirmation plan
5556
-> Discord confirmation button calls POST /agent/confirmations/{plan_id}
5657
-> backend executes the exact frozen plan inline and returns the result
5758
```
5859

59-
Current MVP scope is task-style commands only. The backend agent package keeps
60-
read and write tools separate, applies capability checks before every tool call,
61-
requires confirmation for writes, and audits request/confirmation attempts.
60+
The backend agent package keeps read and write tools separate, applies
61+
capability checks before every tool call, requires confirmation for writes, and
62+
audits request/confirmation attempts. The model only drafts bounded tool calls;
63+
it cannot authorize users or execute integrations. Supported workflows include
64+
tasks, GitHub issues, CRM contacts, member agreements, account provisioning,
65+
and private memory according to the requester's roles.
6266
Long-running service changes should be implemented as PR-based workflows rather
6367
than direct production mutations. Task reads require an explicit project filter
6468
to avoid guild-wide task enumeration.

apps/discord_bot/src/five08/discord_bot/cogs/agent.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from discord import app_commands
1616
from discord.ext import commands
1717

18+
from five08.agent import AgentIdentityContext, PolicyEngine
1819
from five08.discord_bot.config import settings
1920
from five08.discord_bot.utils.audit import DiscordAuditCogMixin
2021
from five08.tls import default_ca_bundle_path
@@ -24,14 +25,12 @@
2425
_MENTION_RATE_LIMIT_MAX_REQUESTS = 5
2526
_PUBLIC_SAFE_CLARIFICATION_MESSAGES = frozenset(
2627
{
27-
"I could not turn that into a supported task action.",
28+
"I could not map that to a supported workflow.",
2829
"Which project should I search?",
2930
"What should the task be?",
3031
}
3132
)
32-
_GENERIC_UNSUPPORTED_AGENT_MESSAGE = (
33-
"I could not turn that into a supported task action."
34-
)
33+
_GENERIC_UNSUPPORTED_AGENT_MESSAGE = "I could not map that to a supported workflow."
3534
_AGENT_RESPONSE_THREAD_NAME = "Agent response"
3635
_AGENT_HELP_REQUESTS = frozenset(
3736
{
@@ -558,25 +557,41 @@ def _agent_capabilities_message(
558557
roles: list[str],
559558
transport: Literal["slash", "mention"] = "mention",
560559
) -> str:
561-
normalized_roles = {role.strip().casefold() for role in roles}
562-
is_admin = bool(normalized_roles & {"admin", "owner", "steering committee"})
563-
is_engineer = (
564-
"engineer" in normalized_roles
565-
or "workflows engineer" in normalized_roles
566-
or is_admin
560+
scopes = PolicyEngine().scopes_for_context(
561+
AgentIdentityContext(
562+
discord_user_id="capability-preview",
563+
organization_id="capability-preview",
564+
roles=roles,
565+
)
567566
)
568567
capabilities: list[str] = []
569-
if is_engineer:
568+
if "project:read" in scopes:
569+
capabilities.append("- Tasks: search a project.")
570+
task_writes: list[str] = []
571+
if "task:create" in scopes:
572+
task_writes.append("create tasks")
573+
if "task:update_own" in scopes:
574+
task_writes.append("update your own tasks")
575+
if task_writes:
576+
capabilities.append(f"- Task writes: {', and '.join(task_writes)}.")
577+
if {"memory:read_self", "memory:write_self"} & scopes:
578+
capabilities.append(
579+
"- Memory: remember and review your private preferences."
580+
)
581+
if "github:issue:read" in scopes:
570582
capabilities.append(
571583
"- GitHub issues: search issues or create new code-task issues."
572584
)
573-
if is_admin:
585+
if "crm:contact:read" in scopes:
574586
capabilities.extend(
575587
[
576588
"- CRM: search contacts, approve/reject onboarding, and submit member agreements.",
577-
"- Ops: create 508 accounts, Authentik SSO users, Outline invites, and mailboxes.",
578589
]
579590
)
591+
if "user:manage" in scopes or "mailbox:create" in scopes:
592+
capabilities.append(
593+
"- Ops: create 508 accounts, Authentik SSO users, Outline invites, and mailboxes."
594+
)
580595
if not capabilities:
581596
lines = [
582597
"I do not see any agent workflows available for your current Discord roles."

apps/worker/src/five08/worker/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class WorkerSettings(SharedSettings):
6161
openrouter_api_key: str | None = None
6262
agent_planner_model: str = "accounts/fireworks/models/kimi-k2p6"
6363
agent_fallback_model: str = "gpt-4.1-mini"
64+
agent_structured_planner_enabled: bool = True
65+
agent_structured_planner_timeout_seconds: float = 6.0
6466
agent_intent_normalizer_enabled: bool = True
6567
agent_intent_normalizer_timeout_seconds: float = 3.0
6668
agent_fast_api_key: str | None = None

0 commit comments

Comments
 (0)