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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,35 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope
# export SERVER_HOST=0.0.0.0
# export SERVER_PORT=8123

# -- Internal Sources (Optional) --
# A fine-grained PAT with read access enables GitHub code, repo, issue, and PR search.
# -- GitHub Search (Optional) --
# A fine-grained PAT with read access enables GitHub repo, code, PR, and CI search.
# If coding reuses this token, it also needs permission to push branches and open PRs.
# export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_...
# export GITHUB_MCP_URL=https://api.githubcopilot.com/mcp/readonly
# Coding (optional). Needs DAYTONA_API_KEY and a GitHub token.

# -- Coding Agent (Optional) --
# Requires Daytona and a GitHub credential: reuse the search PAT above, set a
# dedicated coder PAT, or configure one GitHub App installation.
# export DAYTONA_API_KEY=dtn_...
# export DAYTONA_SNAPSHOT=
# export DAYTONA_TTL_MINUTES=60
# A dedicated write token is preferred; it also powers read-only GitHub MCP
# discovery when GITHUB_PERSONAL_ACCESS_TOKEN is unset.
# Dedicated fine-grained PAT (preferred; classic PATs remain supported):
# export GITHUB_CODER_TOKEN=github_pat_...
# export GITHUB_ALLOWED_REPOS=your-org/*
# GitHub App (instead of GITHUB_CODER_TOKEN):
# export GITHUB_APP_ID=12345
# export GITHUB_APP_INSTALLATION_ID=67890
# export GITHUB_APP_PRIVATE_KEY_BASE64=base64-encoded-pem

# -- PostHog (Optional) --
# Create a personal API key with PostHog's "MCP Server" preset.
# The bundled connection uses CLI mode and is read-only.
# export POSTHOG_PERSONAL_API_KEY=phx_...
# export POSTHOG_MCP_URL=https://mcp.posthog.com/mcp?mode=cli&readonly=true

# -- Linear (Optional) --
# export LINEAR_API_KEY=lin_api_...
# export LINEAR_MCP_URL=https://mcp.linear.app/mcp

# -- Notion (Optional) --
# export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp
# export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm check-types
- run: pnpm test
- name: Install AWS deployment dependencies
run: pnpm --dir deployment/aws install --frozen-lockfile
- name: Test AWS deployment
run: pnpm --dir deployment/aws test
- name: Validate Railway graph
run: node node_modules/railway/dist/iac/bin.js

Expand Down
4 changes: 3 additions & 1 deletion .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export default defineRailway(() => {
DAYTONA_TTL_MINUTES: preserve(),
GITHUB_PERSONAL_ACCESS_TOKEN: preserve(),
GITHUB_CODER_TOKEN: preserve(),
GITHUB_ALLOWED_REPOS: preserve(),
GITHUB_APP_ID: preserve(),
GITHUB_APP_INSTALLATION_ID: preserve(),
GITHUB_APP_PRIVATE_KEY_BASE64: preserve(),
GITHUB_MCP_URL: preserve(),
POSTHOG_PERSONAL_API_KEY: preserve(),
POSTHOG_MCP_URL: preserve(),
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ files.
| AG-UI adapter | `agent/agui.py` | Slack recursion limit and user-facing graph-stop handling |
| Persona | `agent/prompts/` | `system.py` is the base system prompt |
| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before Linear or Notion writes |
| Coder | `agent/coding/` | Daytona sandbox, `open_pull_request`, coder prompt |
| Coder | `agent/coding/` | GitHub credentials, Daytona sandbox, repository publish tools, coder prompt |
| Coder skills | `agent/coding/skills/` | Committed skills. Do not put them in `agent/skills/` |
| Deployment | `.railway/railway.ts` | Two services, declared as code |

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,15 +358,17 @@ knowledge work, and renders UI from model knowledge.
| Variable | Enables |
| ------------------------------------------ | ---------------------------------------------------------------- |
| `TAVILY_API_KEY` | Live web research |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, issue, and PR search |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search |
| `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) |
| `LINEAR_API_KEY` | Hosted Linear MCP |
| `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it |
| `DAYTONA_API_KEY` + a GitHub token | Coding subagent: clone in Daytona, run tests, open a draft PR after `confirm_write` |
| `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` |

Every Linear and Notion mutation is intercepted in code before the MCP request
runs. The interceptor emits `confirm_write` and proceeds only after approval;
reads and rendering do not pause. Draft PR opens use the same card.
reads and rendering do not pause. Coder push plus draft-PR create/update uses the
same card. See [`setup.md`](./setup.md#github) for PAT/App selection and required
GitHub permissions.

[`setup.md`](./setup.md) documents each source, its overrides, and the full
environment contract.
Expand Down
36 changes: 26 additions & 10 deletions agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
from langgraph.checkpoint.memory import MemorySaver
from langgraph.errors import GraphRecursionError

from coding.config import coding_enabled
from coding.config import (
coding_enabled,
github_providers,
log_configuration_warnings,
)
from coding.subagent import build_coder_subagent
from copilotkit.langgraph import copilotkit_emit_message
from langchain_core.runnables.config import ensure_config
from internal_sources import internal_source_tools
from internal_sources import internal_source_toolsets
from prompts import (
BASE_SYSTEM_PROMPT,
DEFAULT_AGENT_DISPLAY_NAME,
Expand Down Expand Up @@ -132,9 +136,9 @@ def _validated_openai_setting(
return value


def graph_recursion_limit() -> int:
def graph_recursion_limit(coding_on: bool | None = None) -> int:
"""Steps the main graph may take in one Slack turn."""
return 80 if coding_enabled() else 25
return 80 if (coding_enabled() if coding_on is None else coding_on) else 25


def build_agent():
Expand Down Expand Up @@ -163,7 +167,13 @@ def build_agent():
use_responses_api=True,
)

internal_tools = internal_source_tools()
providers = github_providers()
log_configuration_warnings(providers)
coding_on = coding_enabled(selection=providers)
source_toolsets = internal_source_toolsets(providers.search)
internal_tools = [
tool for tools in source_toolsets.values() for tool in tools
]
main_tools = (
[web_search, *internal_tools]
if has_web_search
Expand All @@ -180,7 +190,7 @@ def build_agent():
else NO_WEB_SEARCH_TOOL_ADDENDUM
)
system_prompt = system_prompt + (
CODING_ON_ADDENDUM if coding_enabled() else CODING_OFF_ADDENDUM
CODING_ON_ADDENDUM if coding_on else CODING_OFF_ADDENDUM
)

checkpointer = MemorySaver()
Expand All @@ -199,9 +209,15 @@ def build_agent():
"backend": StateBackend(),
"checkpointer": checkpointer,
}
if coding_enabled():
if coding_on:
assert providers.coding is not None
create_kwargs["subagents"] = [
build_coder_subagent(model=llm, checkpointer=checkpointer)
build_coder_subagent(
model=llm,
checkpointer=checkpointer,
provider=providers.coding,
github_tools=source_toolsets.get("github", []),
)
]

agent_graph = create_deep_agent(**create_kwargs)
Expand All @@ -211,14 +227,14 @@ def build_agent():
f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}"
)
print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}")
print(f"[AGENT] coding: {'enabled' if coding_enabled() else 'disabled'}")
print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}")
print(f"[AGENT] internal-source tools: {len(internal_tools)}")
print(f"[AGENT] Main tools: {[t.name for t in main_tools]}")

# A coding turn uses many GitHub MCP reads before task(). 25 steps is
# enough for chat and too low for "read this PR, then code".
# graph.with_config is for direct invoke. Slack/AG-UI must also get
# this value on LangGraphAGUIAgent(config=...) in main.py.
recursion_limit = graph_recursion_limit()
recursion_limit = graph_recursion_limit(coding_on)
print(f"[AGENT] recursion_limit: {recursion_limit}")
return agent_graph.with_config({"recursion_limit": recursion_limit})
5 changes: 4 additions & 1 deletion agent/agui.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ async def run(self, input_data):


def build_agui_agent(graph, *, recursion_limit: int | None = None):
"""Wire the Slack/AG-UI adapter. It does not use graph.with_config."""
"""Wire the Slack/AG-UI adapter with the graph's resolved step limit."""
if recursion_limit is None:
graph_config = getattr(graph, "config", None) or {}
recursion_limit = graph_config.get("recursion_limit")
if recursion_limit is None:
recursion_limit = graph_recursion_limit()
return OpenTagAGUIAgent(
Expand Down
153 changes: 118 additions & 35 deletions agent/coding/config.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,137 @@
"""Env contract for the optional coding subagent."""
"""Environment contract for the optional coding subagent."""

from __future__ import annotations

import logging
import os
from collections.abc import Mapping
from dataclasses import dataclass

# LangGraph always applies a limit (default 25). This is a safety stop for a
# stuck loop, not a budget for a real job.
CODER_RECURSION_LIMIT = 500
from coding.github_credentials import (
GitHubAppProvider,
GitHubCredentialError,
GitHubCredentialProvider,
GitHubPatProvider,
)

CODER_RECURSION_LIMIT = 500
APP_ENV_NAMES = (
"GITHUB_APP_ID",
"GITHUB_APP_INSTALLATION_ID",
"GITHUB_APP_PRIVATE_KEY_BASE64",
)

def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
return os.environ if env is None else env
logger = logging.getLogger(__name__)


def write_token(env: Mapping[str, str] | None = None) -> str | None:
source = _env(env)
for name in ("GITHUB_CODER_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"):
value = (source.get(name) or "").strip()
if value:
return value
return None
@dataclass(frozen=True)
class GitHubProviders:
coding: GitHubCredentialProvider | None
search: GitHubCredentialProvider | None
error: str | None = None
warning: str | None = None


def coding_enabled(env: Mapping[str, str] | None = None) -> bool:
source = _env(env)
return bool((source.get("DAYTONA_API_KEY") or "").strip() and write_token(source))
def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
return os.environ if env is None else env


def allowed_repos(env: Mapping[str, str] | None = None) -> tuple[str, ...]:
raw = (_env(env).get("GITHUB_ALLOWED_REPOS") or "").strip()
if not raw:
return ()
return tuple(part.strip() for part in raw.split(",") if part.strip())
def _value(source: Mapping[str, str], name: str) -> str:
return (source.get(name) or "").strip()


def repo_is_allowed(repo: str, env: Mapping[str, str] | None = None) -> bool:
rules = allowed_repos(env)
if not rules:
return True
owner, _, name = repo.partition("/")
for rule in rules:
if rule.endswith("/*"):
if owner == rule[:-2]:
return True
elif repo == rule:
return True
return False
def github_providers(
env: Mapping[str, str] | None = None,
*,
client=None,
now=None,
) -> GitHubProviders:
"""Select search and coding credentials without making network calls."""
source = _env(env)
search_pat = _value(source, "GITHUB_PERSONAL_ACCESS_TOKEN")
coder_pat = _value(source, "GITHUB_CODER_TOKEN")
app_values = tuple(_value(source, name) for name in APP_ENV_NAMES)
app_configured = any(app_values)
app_complete = all(app_values)

search = GitHubPatProvider(search_pat, client=client) if search_pat else None

if coder_pat and app_complete:
return GitHubProviders(
coding=None,
search=search,
error=(
"GITHUB_CODER_TOKEN and complete GitHub App credentials are both "
"configured; choose exactly one explicit coding method"
),
)
if app_configured and not app_complete:
missing = ", ".join(
name for name, value in zip(APP_ENV_NAMES, app_values) if not value
)
return GitHubProviders(
coding=None,
search=search,
warning=(
"incomplete GitHub App credentials disable coding; missing " + missing
),
)

coding: GitHubCredentialProvider | None
if coder_pat:
coding = GitHubPatProvider(coder_pat, client=client)
elif app_complete:
try:
coding = GitHubAppProvider(
app_id=app_values[0],
installation_id=app_values[1],
private_key_base64=app_values[2],
client=client,
now=now,
)
except GitHubCredentialError as error:
return GitHubProviders(coding=None, search=search, error=str(error))
elif search_pat:
coding = search
else:
coding = None

return GitHubProviders(coding=coding, search=search or coding)


def coding_enabled(
env: Mapping[str, str] | None = None,
*,
selection: GitHubProviders | None = None,
) -> bool:
source = _env(env)
selection = selection or github_providers(source)
return bool(
_value(source, "DAYTONA_API_KEY")
and selection.coding is not None
and selection.error is None
and selection.warning is None
)


def log_configuration_warnings(
selection: GitHubProviders,
env: Mapping[str, str] | None = None,
) -> None:
source = _env(env)
if selection.error:
logger.error("[CODER] GitHub configuration error: %s", selection.error)
if selection.warning:
logger.warning("[CODER] %s", selection.warning)
if _value(source, "GITHUB_ALLOWED_REPOS"):
logger.warning(
"[CODER] GITHUB_ALLOWED_REPOS is ignored; GitHub permissions now "
"define repository access"
)


def ttl_minutes(env: Mapping[str, str] | None = None) -> int:
raw = (_env(env).get("DAYTONA_TTL_MINUTES") or "").strip()
raw = _value(_env(env), "DAYTONA_TTL_MINUTES")
try:
value = int(raw)
except ValueError:
Expand All @@ -57,5 +140,5 @@ def ttl_minutes(env: Mapping[str, str] | None = None) -> int:


def snapshot_id(env: Mapping[str, str] | None = None) -> str | None:
value = (_env(env).get("DAYTONA_SNAPSHOT") or "").strip()
value = _value(_env(env), "DAYTONA_SNAPSHOT")
return value or None
Loading