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
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ INTELLIGENCE_API_KEY=cpk-...
# Get an API key at https://platform.openai.com/api-keys.
OPENAI_API_KEY=sk-...

# Optional web research and model override.
# Optional web research and model behavior overrides.
# TAVILY_API_KEY=tvly-...
# OPENAI_MODEL=gpt-5.5
# OPENAI_REASONING_EFFORT=low
# OPENAI_VERBOSITY=low

# -- Internal Sources (Optional) --
# LINEAR_API_KEY=lin_api_...
# NOTION_TOKEN=ntn_...
# NOTION_MCP_AUTH_TOKEN=choose-a-shared-secret
# Remote Notion requires both values; no local sidecar is included.
# NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp
# NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token
2 changes: 2 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export default defineRailway(() => {
OPENAI_API_KEY: preserve(),
TAVILY_API_KEY: preserve(),
LINEAR_API_KEY: preserve(),
NOTION_MCP_URL: preserve(),
NOTION_MCP_AUTH_TOKEN: preserve(),
},
});

Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# OpenTag

OpenTag is an open-source research and triage agent for Slack and Microsoft
Teams. It runs on CopilotKit Channels, connects to CopilotKit Intelligence, and
uses a Python LangGraph deep agent over AG-UI.
OpenTag is an open-source on-call triage assistant for Slack and Microsoft
Teams, with research available on demand. It runs on CopilotKit Channels,
connects to CopilotKit Intelligence, and uses a Python LangGraph agent over
AG-UI.

The launch supports:

Expand All @@ -27,7 +28,7 @@ agent (Python + LangGraph deepagents)
├── OpenAI
├── Tavily (optional)
├── Linear MCP (optional)
└── Notion MCP (optional local sidecar)
└── Notion MCP (optional remote server)
```

There is one canonical runtime host: [`server.ts`](./server.ts).
Expand Down Expand Up @@ -130,10 +131,10 @@ Slack app**. Reusing it preserves the bot user, workspace installation, and
## Optional research sources

- `TAVILY_API_KEY` enables live web research. Without it, OpenTag still chats,
plans, uses its virtual filesystem, and renders UI from model knowledge.
triages requests and renders UI from model knowledge.
- `LINEAR_API_KEY` enables the hosted Linear MCP.
- Notion is optional for local development: put its token and shared
`NOTION_MCP_AUTH_TOKEN` in the root `.env` and run `pnpm notion-mcp`.
- Notion is optional and remote-only. Set both `NOTION_MCP_URL` and
`NOTION_MCP_AUTH_TOKEN`; setting only one disables the integration.

Every Linear and Notion mutation is intercepted in code before the MCP request
runs. The interceptor emits `confirm_write` and proceeds only after approval;
Expand All @@ -153,8 +154,8 @@ The runtime reaches the agent over Railway private networking and embeds the
managed `open-tag` Channel. Railway sets
`INTELLIGENCE_CHANNEL_NAME=open-tag`; Intelligence owns both platform
adapters. The runtime API key is preserved. OpenAI is required on `agent`;
Tavily and Linear secrets are optional. Connecting both services to `main`
enables GitHub-triggered deployments after merges.
Tavily, Linear, and remote Notion settings are optional. Connecting both
services to `main` enables GitHub-triggered deployments after merges.

The repository configuration does not mutate the existing production Railway
project. Inventory and cutover should happen after Railway authentication.
Expand Down
78 changes: 65 additions & 13 deletions agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,96 @@
"""OpenTag's Deep Agents research assistant."""
"""OpenTag's triage-first Deep Agent."""

import os
from pathlib import Path

from copilotkit import CopilotKitMiddleware
from deepagents import create_deep_agent
from deepagents import (
GeneralPurposeSubagentProfile,
HarnessProfile,
create_deep_agent,
register_harness_profile,
)
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver

from internal_sources import internal_source_tools
from prompts import (
BASE_SYSTEM_PROMPT,
NO_RESEARCH_TOOL_ADDENDUM,
RESEARCH_TOOL_ADDENDUM,
NO_WEB_SEARCH_TOOL_ADDENDUM,
WEB_SEARCH_TOOL_ADDENDUM,
)
from tools import research
from tools import web_search

load_dotenv(Path(__file__).resolve().parent.parent / ".env")

VALID_REASONING_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh", "max"}
)
VALID_VERBOSITY_LEVELS = frozenset({"low", "medium", "high"})

# Deep Agents adds shell execution and a general-purpose delegation tool by
# default. OpenTag has no sandbox for execute, and delegating routine turns to
# another agent adds latency without improving triage.
register_harness_profile(
"openai",
HarnessProfile(
excluded_tools=frozenset({"execute"}),
general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
),
)


def _validated_openai_setting(
name: str,
*,
default: str,
allowed: frozenset[str],
) -> str:
"""Read and validate a non-secret OpenAI tuning setting."""
value = os.environ.get(name, default).strip().lower()
if value not in allowed:
choices = ", ".join(sorted(allowed))
raise RuntimeError(f"Invalid {name}: expected one of {choices}")
return value


def build_agent():
"""Build the OpenTag research graph."""
"""Build the OpenTag triage graph."""
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("Missing OPENAI_API_KEY environment variable")

has_research = bool(os.environ.get("TAVILY_API_KEY"))
reasoning_effort = _validated_openai_setting(
"OPENAI_REASONING_EFFORT",
default="low",
allowed=VALID_REASONING_EFFORTS,
)
verbosity = _validated_openai_setting(
"OPENAI_VERBOSITY",
default="low",
allowed=VALID_VERBOSITY_LEVELS,
)
has_web_search = bool(os.environ.get("TAVILY_API_KEY"))
model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5")
llm = ChatOpenAI(
model=model_name,
api_key=api_key,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
)

internal_tools = internal_source_tools()
main_tools = (
[research, *internal_tools]
if has_research
[web_search, *internal_tools]
if has_web_search
else [*internal_tools]
)

system_prompt = BASE_SYSTEM_PROMPT + (
RESEARCH_TOOL_ADDENDUM if has_research else NO_RESEARCH_TOOL_ADDENDUM
WEB_SEARCH_TOOL_ADDENDUM
if has_web_search
else NO_WEB_SEARCH_TOOL_ADDENDUM
)

agent_graph = create_deep_agent(
Expand All @@ -52,9 +101,12 @@ def build_agent():
checkpointer=MemorySaver(),
)

print(f"[AGENT] OpenTag Deep Research Agent created with model={model_name}")
print(f"[AGENT] research: {'enabled' if has_research else 'disabled'}")
print(
"[AGENT] OpenTag Agent created "
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] internal-source tools: {len(internal_tools)}")
print(f"[AGENT] Main tools: {[t.name for t in main_tools]}")

return agent_graph.with_config({"recursion_limit": 100})
return agent_graph.with_config({"recursion_limit": 25})
21 changes: 19 additions & 2 deletions agent/internal_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"notion": {
"token_env": "NOTION_MCP_AUTH_TOKEN",
"url_env": "NOTION_MCP_URL",
"default_url": "http://127.0.0.1:3001/mcp",
"default_url": None,
},
}
MCP_LOAD_TIMEOUT_SECONDS = 8.0
Expand Down Expand Up @@ -57,12 +57,29 @@ def _configured_connections(
connections: dict[str, dict[str, Any]] = {}
for name, config in MCP_SERVERS.items():
token = env.get(config["token_env"])
configured_url = env.get(config["url_env"])
url = configured_url or config["default_url"]
if not token:
if configured_url:
logger.warning(
"[TOOLS] skipping %s: %s must be set with %s",
name,
config["token_env"],
config["url_env"],
)
continue
if not url:
logger.warning(
"[TOOLS] skipping %s: %s must be set with %s",
name,
config["url_env"],
config["token_env"],
)
continue

connections[name] = {
"transport": "streamable_http",
"url": env.get(config["url_env"], config["default_url"]),
"url": url,
"headers": {"Authorization": f"Bearer {token}"},
}
return connections
Expand Down
13 changes: 7 additions & 6 deletions agent/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""FastAPI server for the OpenTag research agent."""
"""FastAPI server for the OpenTag triage agent."""

from collections.abc import Mapping
import os
Expand All @@ -12,14 +12,15 @@
from agent import build_agent

app = FastAPI(
title="OpenTag Deep Research Agent",
description="A research assistant powered by Deep Agents and CopilotKit",
title="OpenTag Agent",
description="An on-call triage assistant powered by Deep Agents and CopilotKit",
version="0.1.0",
)

AGENT_NAME = "opentag_research"
AGENT_DESCRIPTION = (
"OpenTag deep research assistant — plans, searches, and synthesizes cited briefs"
"OpenTag on-call triage assistant for incidents, research, and "
"Linear/Notion workflows"
)

# Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access.
Expand All @@ -40,7 +41,7 @@
@app.get("/health")
def health():
"""Return service health."""
return {"status": "ok", "service": "opentag-research-agent", "version": "0.1.0"}
return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"}


def local_server_port(env: Mapping[str, str] = os.environ) -> int:
Expand Down Expand Up @@ -70,7 +71,7 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int:
path="/",
)

print("[SERVER] Deep Research Agent registered at /")
print("[SERVER] OpenTag Agent registered at /")
except Exception as error:
print(f"[ERROR] Failed to build agent: {error}", file=sys.stderr)
raise
Expand Down
14 changes: 6 additions & 8 deletions agent/prompts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
"""OpenTag agent prompts."""

from .research import (
NO_RESEARCH_TOOL_ADDENDUM,
RESEARCH_TOOL_ADDENDUM,
RESEARCHER_SYSTEM_PROMPT,
)
from .system import SYSTEM_PROMPT, WORKFLOW_PROMPT
from .tools import TOOLS_PROMPT
from .web_search import (
NO_WEB_SEARCH_TOOL_ADDENDUM,
WEB_SEARCH_TOOL_ADDENDUM,
)

BASE_SYSTEM_PROMPT = SYSTEM_PROMPT + TOOLS_PROMPT + WORKFLOW_PROMPT

__all__ = [
"BASE_SYSTEM_PROMPT",
"NO_RESEARCH_TOOL_ADDENDUM",
"RESEARCH_TOOL_ADDENDUM",
"RESEARCHER_SYSTEM_PROMPT",
"NO_WEB_SEARCH_TOOL_ADDENDUM",
"WEB_SEARCH_TOOL_ADDENDUM",
]
33 changes: 0 additions & 33 deletions agent/prompts/research.py

This file was deleted.

27 changes: 14 additions & 13 deletions agent/prompts/system.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
"""Core OpenTag persona and workflow."""

SYSTEM_PROMPT = """You are OpenTag's Deep Research Assistant, an expert at planning and
executing comprehensive research on any topic.
SYSTEM_PROMPT = """You are OpenTag, your team's on-call triage assistant for fast
incident support, research, and Linear/Notion workflows.

Hard rules (ALWAYS follow):
- NEVER output raw JSON, data structures, or code blocks in your messages
- Communicate with the user only in natural, readable prose
- When you receive data from research or from your own knowledge, synthesize it into insights
- Lead with the answer and keep routine replies concise and action-oriented
- When you receive source data or use your own knowledge, synthesize it into insights
- Prefer rendering results as UI components (tables, cards, links, etc.) when the
frontend offers them, rather than large blocks of raw markdown
"""

WORKFLOW_PROMPT = """
Your workflow:
1. PLAN: Create a research plan using write_todos with clear, actionable steps
2. INVESTIGATE: Work through each step, drawing on the tools available to you
3. SYNTHESIZE: Write a final report to /reports/final_report.md using write_file

Important guidelines:
- Always start by creating a research plan with write_todos
- You write all files - compile findings into a comprehensive report
- Update todos as you complete each step
- Always maintain a professional, comprehensive research style"""
Operating mode:
- Answer ordinary requests directly. Do not turn them into research projects
- Use the smallest number of tool calls needed to answer reliably
- Use write_todos only for explicitly substantial, multi-step work where a
visible plan helps the user
- Use filesystem tools only when the user asks for an artifact or when
substantial, multi-step work needs durable notes
- Do not write a report by default
- When a request is ambiguous in a way that changes the action, ask one focused
question; otherwise make a reasonable, stated assumption and proceed"""
17 changes: 17 additions & 0 deletions agent/prompts/web_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Prompt guidance for optional direct web search."""

WEB_SEARCH_TOOL_ADDENDUM = """

Live web research is available via web_search(query, max_results=5):
- Use it when a request needs current or external information
- Start with one focused query and search again only when a material gap remains
- The tool returns source snippets with URLs; synthesize the evidence and cite
the useful sources rather than dumping raw results
"""

NO_WEB_SEARCH_TOOL_ADDENDUM = """

You do NOT have a live web research tool available right now. Answer from your
own knowledge, state plainly when you cannot look up current or external
information, and never claim to have searched the web.
"""
Loading