Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope
# export SERVER_HOST=0.0.0.0
# export SERVER_PORT=8123

# -- Parallel Search (Optional) --
# No account or API key is required for Parallel Search MCP.
# export PARALLEL_MCP_URL=https://search.parallel.ai/mcp

# -- 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.
Expand Down
1 change: 1 addition & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineRailway(() => {
LINEAR_API_KEY: preserve(),
NOTION_MCP_URL: preserve(),
NOTION_MCP_AUTH_TOKEN: preserve(),
PARALLEL_MCP_URL: preserve(),
},
});

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ or one directory, and none of them require touching the Channel lifecycle.
| The persona and behavior | [`agent/prompts/`](./agent/prompts) | `system.py` holds the base system prompt |
| The agent itself | [`agent/agent.py`](./agent/agent.py) | A LangGraph deep agent; model and reasoning effort come from the environment |
| **The agent framework** | `AGENT_URL` | Point it at _any_ AG-UI-compatible agent. The runtime speaks AG-UI over HTTP and does not care what is on the other end |
| Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their credentials are present |
| Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their opt-in configuration is present |
| What gets rendered in chat | [`app/components/`](./app/components), [`app/tools/`](./app/tools) | Issue cards, tables, charts, diagrams |
| Mentions, commands, triggers | [`app/channel.tsx`](./app/channel.tsx) | The whole Channel surface in one file |
| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write` |
Expand Down Expand Up @@ -320,7 +320,8 @@ agent (Python + LangGraph deepagents)
├── GitHub MCP (optional, read-only)
├── PostHog MCP (optional, read-only)
├── Linear MCP (optional)
└── Notion MCP (optional remote server)
├── Notion MCP (optional remote server)
└── Parallel Search MCP (optional, no account or API key)
```

| You run | CopilotKit Intelligence manages |
Expand Down Expand Up @@ -362,6 +363,7 @@ knowledge work, and renders UI from model knowledge.
| `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 |
| `PARALLEL_MCP_URL` | Live web search and URL fetching with no account or API key |
| `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
Expand All @@ -370,6 +372,11 @@ reads and rendering do not pause. Coder push plus draft-PR create/update uses th
same card. See [`setup.md`](./setup.md#github) for PAT/App selection and required
GitHub permissions.

Set `PARALLEL_MCP_URL=https://search.parallel.ai/mcp` to opt into Parallel
Search MCP. Its tools are exposed as `parallel_web_search` and
`parallel_web_fetch`, so Tavily's existing `web_search` remains available when
both sources are configured.

[`setup.md`](./setup.md) documents each source, its overrides, and the full
environment contract.

Expand Down
28 changes: 20 additions & 8 deletions agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
BASE_SYSTEM_PROMPT,
DEFAULT_AGENT_DISPLAY_NAME,
NO_WEB_SEARCH_TOOL_ADDENDUM,
PARALLEL_SEARCH_TOOL_ADDENDUM,
WEB_SEARCH_TOOL_ADDENDUM,
CODING_OFF_ADDENDUM,
CODING_ON_ADDENDUM,
Expand Down Expand Up @@ -157,7 +158,7 @@ def build_agent():
default="low",
allowed=VALID_VERBOSITY_LEVELS,
)
has_web_search = bool(os.environ.get("TAVILY_API_KEY"))
has_tavily_search = bool(os.environ.get("TAVILY_API_KEY"))
model_name = os.environ.get("OPENAI_MODEL", "gpt-5.5")
llm = ChatOpenAI(
model=model_name,
Expand All @@ -174,22 +175,32 @@ def build_agent():
internal_tools = [
tool for tools in source_toolsets.values() for tool in tools
]
parallel_tool_names = {
tool.name for tool in source_toolsets.get("parallel", [])
}
has_parallel_search = {
"parallel_web_search",
"parallel_web_fetch",
}.issubset(parallel_tool_names)
main_tools = (
[web_search, *internal_tools]
if has_web_search
if has_tavily_search
else [*internal_tools]
)

search_prompt = ""
if has_tavily_search:
search_prompt += WEB_SEARCH_TOOL_ADDENDUM
if has_parallel_search:
search_prompt += PARALLEL_SEARCH_TOOL_ADDENDUM
if not search_prompt:
search_prompt = NO_WEB_SEARCH_TOOL_ADDENDUM
agent_display_name = (
os.environ.get("AGENT_DISPLAY_NAME", DEFAULT_AGENT_DISPLAY_NAME).strip()
or DEFAULT_AGENT_DISPLAY_NAME
)
system_prompt = build_base_system_prompt(agent_display_name) + (
WEB_SEARCH_TOOL_ADDENDUM
if has_web_search
else NO_WEB_SEARCH_TOOL_ADDENDUM
)
system_prompt = system_prompt + (
system_prompt = build_base_system_prompt(agent_display_name) + search_prompt
system_prompt += (
CODING_ON_ADDENDUM if coding_on else CODING_OFF_ADDENDUM
)

Expand Down Expand Up @@ -226,6 +237,7 @@ def build_agent():
"[AGENT] OpenTag Agent created "
f"with model={model_name}, reasoning={reasoning_effort}, verbosity={verbosity}"
)
has_web_search = has_tavily_search or has_parallel_search
print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}")
print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}")
print(f"[AGENT] internal-source tools: {len(internal_tools)}")
Expand Down
42 changes: 29 additions & 13 deletions agent/internal_sources.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Optional GitHub, PostHog, Linear, and Notion MCP integrations."""
"""Optional GitHub, PostHog, Linear, Notion, and Parallel MCP integrations."""

import asyncio
import logging
Expand Down Expand Up @@ -39,6 +39,12 @@
"url_env": "NOTION_MCP_URL",
"default_url": None,
},
"parallel": {
"token_env": None,
"url_env": "PARALLEL_MCP_URL",
"default_url": None,
"tool_name_prefix": True,
},
}
MCP_LOAD_TIMEOUT_SECONDS = 8.0
GITHUB_READ_TOOL_ALLOWLIST = frozenset(
Expand Down Expand Up @@ -102,37 +108,41 @@ def _configured_connections(
connections: dict[str, dict[str, Any]] = {}
for name, config in MCP_SERVERS.items():
is_github = name == "github"
token = None if is_github else env.get(config.get("token_env", ""))
token_env = config.get("token_env")
token = env.get(token_env) if token_env else None
configured_url = env.get(config["url_env"])
url = configured_url or config["default_url"]
if (is_github and github_provider is None) or (not is_github and not token):
if (is_github and github_provider is None) or (token_env and not token):
if configured_url:
logger.warning(
"[TOOLS] skipping %s: %s must be set with %s",
name,
config.get("token_env", "GitHub credentials"),
token_env or "GitHub credentials",
config["url_env"],
)
continue
if not url:
logger.warning(
"[TOOLS] skipping %s: %s must be set with %s",
name,
config["url_env"],
config.get("token_env", "GitHub credentials"),
)
if is_github or token_env:
logger.warning(
"[TOOLS] skipping %s: %s must be set with %s",
name,
config["url_env"],
token_env or "GitHub credentials",
)
continue

headers = dict(config.get("headers", {}))
if token:
headers["Authorization"] = f"Bearer {token}"
connections[name] = {
connection: dict[str, Any] = {
"transport": "streamable_http",
"url": url,
"headers": headers,
}
if headers:
connection["headers"] = headers
if is_github:
connections[name]["auth"] = GitHubProviderAuth(github_provider)
connection["auth"] = GitHubProviderAuth(github_provider)
connections[name] = connection
return connections


Expand All @@ -151,9 +161,15 @@ async def _load_tools(
for name, connection in connections.items():
try:
confirmation = WriteConfirmationInterceptor()
client_options = (
{"tool_name_prefix": True}
if MCP_SERVERS[name].get("tool_name_prefix")
else {}
)
client = MultiServerMCPClient(
{name: connection},
tool_interceptors=[confirmation],
**client_options,
)
tools = await asyncio.wait_for(
client.get_tools(),
Expand Down
2 changes: 2 additions & 0 deletions agent/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from .web_search import (
NO_WEB_SEARCH_TOOL_ADDENDUM,
PARALLEL_SEARCH_TOOL_ADDENDUM,
WEB_SEARCH_TOOL_ADDENDUM,
)

Expand All @@ -33,6 +34,7 @@ def build_base_system_prompt(
"current_date_context",
"current_date_prompt",
"NO_WEB_SEARCH_TOOL_ADDENDUM",
"PARALLEL_SEARCH_TOOL_ADDENDUM",
"WEB_SEARCH_TOOL_ADDENDUM",
"CODING_ON_ADDENDUM",
"CODING_OFF_ADDENDUM",
Expand Down
12 changes: 12 additions & 0 deletions agent/prompts/web_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
the useful sources rather than dumping raw results
"""

PARALLEL_SEARCH_TOOL_ADDENDUM = """

Parallel live web tools are also available:
- Call parallel_web_search with a focused, non-empty objective and at least one
concise search query when current web evidence would improve the answer
- Search results include source excerpts that are usually enough to answer;
synthesize the evidence and cite the useful source URLs
- Use parallel_web_fetch for specific URLs or when search excerpts are
conflicting or clearly insufficient, not as a default follow-up to every
search
"""

NO_WEB_SEARCH_TOOL_ADDENDUM = """

You do NOT have a live web research tool available right now. Answer from your
Expand Down
101 changes: 98 additions & 3 deletions agent/tests/test_agent_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import StructuredTool
from langchain_openai import ChatOpenAI as RealChatOpenAI
from pydantic import Field

Expand Down Expand Up @@ -34,11 +35,27 @@ def with_config(self, config):
return self


def build_with_captured_configuration(monkeypatch):
def build_with_captured_configuration(
monkeypatch,
*,
tavily=False,
parallel=False,
internal_tools=None,
):
captured = {}

monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
if tavily:
monkeypatch.setenv("TAVILY_API_KEY", "tvly-test")
else:
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
if parallel:
monkeypatch.setenv(
"PARALLEL_MCP_URL",
"https://search.parallel.ai/mcp",
)
else:
monkeypatch.delenv("PARALLEL_MCP_URL", raising=False)
monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False)
monkeypatch.delenv("LINEAR_API_KEY", raising=False)
Expand All @@ -48,7 +65,11 @@ def build_with_captured_configuration(monkeypatch):
monkeypatch.delenv("GITHUB_APP_ID", raising=False)
monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False)
monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False)
monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {})
monkeypatch.setattr(
agent_mod,
"internal_source_toolsets",
lambda _provider: {"parallel": internal_tools or []} if parallel else {},
)

def fake_chat_openai(**kwargs):
captured["model"] = kwargs
Expand Down Expand Up @@ -86,6 +107,78 @@ def test_build_agent_accepts_valid_reasoning_and_verbosity_overrides(monkeypatch
assert captured["model"]["verbosity"] == "medium"


def test_build_agent_uses_parallel_prompt_without_tavily(monkeypatch):
parallel_tools = [
StructuredTool.from_function(
func=lambda: "results",
name="parallel_web_search",
description="Search the live web",
),
StructuredTool.from_function(
func=lambda: "page",
name="parallel_web_fetch",
description="Fetch a web page",
),
]

_, captured = build_with_captured_configuration(
monkeypatch,
parallel=True,
internal_tools=parallel_tools,
)

prompt = captured["agent"]["system_prompt"]
assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM in prompt
assert agent_mod.NO_WEB_SEARCH_TOOL_ADDENDUM not in prompt
assert captured["agent"]["tools"] == parallel_tools


def test_build_agent_does_not_advertise_parallel_when_discovery_fails(
monkeypatch,
capsys,
):
_, captured = build_with_captured_configuration(
monkeypatch,
parallel=True,
internal_tools=[],
)

prompt = captured["agent"]["system_prompt"]
assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM not in prompt
assert agent_mod.NO_WEB_SEARCH_TOOL_ADDENDUM in prompt
assert captured["agent"]["tools"] == []
assert "[AGENT] web search: disabled" in capsys.readouterr().out


def test_build_agent_keeps_tavily_first_when_parallel_is_also_enabled(
monkeypatch,
):
parallel_tools = [
StructuredTool.from_function(
func=lambda: "results",
name="parallel_web_search",
description="Search the live web",
),
StructuredTool.from_function(
func=lambda: "page",
name="parallel_web_fetch",
description="Fetch a web page",
),
]

_, captured = build_with_captured_configuration(
monkeypatch,
tavily=True,
parallel=True,
internal_tools=parallel_tools,
)

prompt = captured["agent"]["system_prompt"]
assert agent_mod.WEB_SEARCH_TOOL_ADDENDUM in prompt
assert agent_mod.PARALLEL_SEARCH_TOOL_ADDENDUM in prompt
assert captured["agent"]["tools"] == [agent_mod.web_search, *parallel_tools]


def test_build_agent_uses_configured_display_name(monkeypatch):
monkeypatch.setenv("AGENT_DISPLAY_NAME", "Kite")

Expand Down Expand Up @@ -167,6 +260,7 @@ def _generate(
monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False)
monkeypatch.delenv("LINEAR_API_KEY", raising=False)
monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False)
monkeypatch.delenv("PARALLEL_MCP_URL", raising=False)
monkeypatch.delenv("DAYTONA_API_KEY", raising=False)
monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False)
monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: model)
Expand Down Expand Up @@ -194,6 +288,7 @@ def _configure_minimal_environment(monkeypatch):
monkeypatch.delenv("POSTHOG_PERSONAL_API_KEY", raising=False)
monkeypatch.delenv("LINEAR_API_KEY", raising=False)
monkeypatch.delenv("NOTION_MCP_AUTH_TOKEN", raising=False)
monkeypatch.delenv("PARALLEL_MCP_URL", raising=False)
monkeypatch.delenv("DAYTONA_API_KEY", raising=False)
monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_APP_ID", raising=False)
Expand Down
Loading