mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed90426d96 | ||
|
|
9c32b9d84c | ||
|
|
4985415e61 | ||
|
|
a29deefef7 | ||
|
|
7ffb4f87c5 | ||
|
|
057dab0ec0 | ||
|
|
89229c5c76 | ||
|
|
e20e426dd5 | ||
|
|
d49c559fce | ||
|
|
de84d22087 | ||
|
|
f3166b5bbd | ||
|
|
0d1cc32b23 | ||
|
|
e33b4b4b39 | ||
|
|
c9bbb5aee7 | ||
|
|
ed63d34ed7 | ||
|
|
d15c57d80c | ||
|
|
73eaacad35 | ||
|
|
0cec9fd1c3 | ||
|
|
7a7df247c8 | ||
|
|
cb96d03b58 | ||
|
|
d8a8a78691 | ||
|
|
120d299d85 | ||
|
|
2738e31d1f | ||
|
|
e5928449f7 | ||
|
|
2c84756b1f | ||
|
|
68cc9427ff | ||
|
|
37f979979c | ||
|
|
7d4d36a422 | ||
|
|
8c0fbcf5ef | ||
|
|
8aa1f1f785 | ||
|
|
75400e3b9c | ||
|
|
dfbb8d78ec | ||
|
|
750d9e7a62 | ||
|
|
392a708db9 | ||
|
|
a8394cfa15 |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# Contributing to PraisonAI
|
||||
|
||||
Thank you for your interest! Here's how to get started:
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork and install dependencies:
|
||||
```bash
|
||||
git clone https://github.com/your-username/PraisonAI.git
|
||||
cd PraisonAI
|
||||
pip install -e src/praisonai-agents/
|
||||
pip install -e src/praisonai/
|
||||
```
|
||||
3. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
4. Make your changes
|
||||
5. Submit a pull request
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow existing code style (PEP 8)
|
||||
- Add tests for new features and run them locally using pytest:
|
||||
```bash
|
||||
cd src/praisonai-agents && pytest tests/
|
||||
```
|
||||
- Update documentation as needed
|
||||
- Check existing issues or open a new one to discuss your idea before implementing large changes
|
||||
|
||||
For questions or support, please visit our [GitHub Issues](https://github.com/MervinPraison/PraisonAI/issues).
|
||||
@@ -786,6 +786,7 @@ Methods:
|
||||
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.32" \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.32" \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.32" \
|
||||
"praisonai>=4.6.37" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Linear Agent Example for PraisonAI.
|
||||
|
||||
Demonstrates how to create a Linear bot that responds to issue mentions
|
||||
and assignments with full coding capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.bots import LinearBot
|
||||
|
||||
# Create an agent with Linear tools and coding capabilities
|
||||
agent = Agent(
|
||||
name="PraisonAI Coder",
|
||||
instructions="""
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
""",
|
||||
llm="gpt-4o-mini",
|
||||
tools=[
|
||||
"linear_search_issues",
|
||||
"linear_get_issue",
|
||||
"linear_add_comment",
|
||||
"linear_update_issue",
|
||||
"linear_list_teams",
|
||||
"linear_list_issue_states",
|
||||
"github_create_branch",
|
||||
"github_commit_and_push",
|
||||
"github_create_pull_request",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"execute_command"
|
||||
],
|
||||
memory=True,
|
||||
web_search=True,
|
||||
auto_approve_tools=True,
|
||||
)
|
||||
|
||||
# Create Linear bot
|
||||
bot = LinearBot(
|
||||
token="your-linear-oauth-token", # or set LINEAR_OAUTH_TOKEN env var
|
||||
signing_secret="your-webhook-secret", # or set LINEAR_WEBHOOK_SECRET env var
|
||||
agent=agent,
|
||||
webhook_port=8080,
|
||||
)
|
||||
|
||||
async def main():
|
||||
"""Start the Linear bot."""
|
||||
print("Starting Linear bot...")
|
||||
print("Set up your Linear webhook to point to: http://your-host:8080/webhook")
|
||||
print("Configure AgentSession and Comment events in Linear webhook settings")
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
await bot.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
platform: linear
|
||||
token: ${LINEAR_OAUTH_TOKEN}
|
||||
signing_secret: ${LINEAR_WEBHOOK_SECRET}
|
||||
webhook_port: 8080
|
||||
|
||||
agent:
|
||||
name: "PraisonAI Coder"
|
||||
instructions: |
|
||||
You are an autonomous coding agent integrated with Linear.
|
||||
|
||||
When you are mentioned or assigned an issue:
|
||||
1. Analyze the issue description and requirements
|
||||
2. Break down complex tasks into smaller steps
|
||||
3. Use available tools to implement solutions
|
||||
4. Update the issue with progress comments
|
||||
5. Create GitHub pull requests when code changes are needed
|
||||
|
||||
Focus on being helpful, thorough, and providing clear updates.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- linear_search_issues
|
||||
- linear_get_issue
|
||||
- linear_add_comment
|
||||
- linear_update_issue
|
||||
- linear_list_teams
|
||||
- linear_list_issue_states
|
||||
- github_create_branch
|
||||
- github_commit_and_push
|
||||
- github_create_pull_request
|
||||
- read_file
|
||||
- write_file
|
||||
- execute_command
|
||||
memory: true
|
||||
web_search: true
|
||||
auto_approve_tools: true
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""W1 real agentic smoke test (high-level BotOS API, no manual mgr).
|
||||
|
||||
Uses the user-facing API only — ``Bot``, ``BotOS``, ``InMemoryIdentityResolver`` —
|
||||
and exercises the same cross-platform-continuity scenario as
|
||||
``smoke_w1_real.py`` but without touching ``BotSessionManager`` directly.
|
||||
|
||||
The mock-platform pattern: we instantiate two ``Bot``s but reach into
|
||||
their adapter ``_session`` to drive the chat manually — this proves the
|
||||
wire-up from ``BotOS(identity_resolver=...)`` → ``Bot._identity_resolver``
|
||||
→ adapter splice works end-to-end.
|
||||
|
||||
Run::
|
||||
|
||||
# From the repository root:
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_botos_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import (
|
||||
FileIdentityResolver,
|
||||
DefaultSessionStore,
|
||||
)
|
||||
from praisonai.bots import Bot, BotOS
|
||||
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
model = "claude-3-5-haiku-latest"
|
||||
elif os.getenv("GOOGLE_API_KEY"):
|
||||
model = "gemini-1.5-flash"
|
||||
else:
|
||||
model = "gpt-4o-mini"
|
||||
print(f"Using model: {model}")
|
||||
|
||||
agent = Agent(
|
||||
name="W1BotOSTester",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Always remember facts the user "
|
||||
"tells you and recall them precisely when asked."
|
||||
),
|
||||
llm=model,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Persistent FileIdentityResolver — survives restart.
|
||||
resolver = FileIdentityResolver(path=f"{tmp}/identity.json")
|
||||
resolver.link("telegram", "tg-aaa", "alice-prod")
|
||||
resolver.link("discord", "dc-bbb", "alice-prod")
|
||||
store = DefaultSessionStore(session_dir=f"{tmp}/sessions")
|
||||
|
||||
# Create two Bots; BotOS wires the resolver to each.
|
||||
bot_t = Bot("telegram", agent=agent, token="dummy")
|
||||
bot_d = Bot("discord", agent=agent, token="dummy")
|
||||
|
||||
os_ = BotOS(bots=[bot_t, bot_d], identity_resolver=resolver)
|
||||
assert bot_t._identity_resolver is resolver
|
||||
assert bot_d._identity_resolver is resolver
|
||||
print(f"BotOS wired resolver to {os_.list_bots()}")
|
||||
|
||||
# We don't need the network; build adapters lazily and use their
|
||||
# _session managers directly. Splicing logic is the same code
|
||||
# path real adapters take during start().
|
||||
adapter_t = bot_t._build_adapter()
|
||||
adapter_d = bot_d._build_adapter()
|
||||
# Inject the shared store into both managers so unification works.
|
||||
adapter_t._session._store = store
|
||||
adapter_d._session._store = store
|
||||
|
||||
out_t = await adapter_t._session.chat(
|
||||
agent, "tg-aaa",
|
||||
"Remember this: my favourite music genre is darkwave.",
|
||||
chat_id="100", user_name="Alice",
|
||||
)
|
||||
print(f"\n[Telegram] {out_t}\n")
|
||||
|
||||
out_d = await adapter_d._session.chat(
|
||||
agent, "dc-bbb",
|
||||
"Hey, what was my favourite music genre I just told you?",
|
||||
chat_id="200", user_name="Alice",
|
||||
)
|
||||
print(f"[Discord] {out_d}\n")
|
||||
|
||||
if out_d and "darkwave" in out_d.lower():
|
||||
print("PASS: BotOS-wired cross-platform continuity verified.")
|
||||
return 0
|
||||
print("FAIL: agent did not recall via BotOS-wired path.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""W1 real agentic smoke test (bypasses pytest conftest gating).
|
||||
|
||||
Runs::
|
||||
|
||||
cd src/praisonai
|
||||
python ../../scripts/smoke_w1_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
model = "claude-3-5-haiku-latest"
|
||||
elif os.getenv("GOOGLE_API_KEY"):
|
||||
model = "gemini-1.5-flash"
|
||||
else:
|
||||
model = "gpt-4o-mini"
|
||||
print(f"Using model: {model}")
|
||||
|
||||
agent = Agent(
|
||||
name="W1Tester",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Always remember facts the user "
|
||||
"tells you and recall them precisely when asked."
|
||||
),
|
||||
llm=model,
|
||||
)
|
||||
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("telegram", "tg-12345", "alice-global")
|
||||
resolver.link("discord", "dc-67890", "alice-global")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_path:
|
||||
store = DefaultSessionStore(session_dir=tmp_path)
|
||||
mgr_t = BotSessionManager(
|
||||
platform="telegram", identity_resolver=resolver, store=store
|
||||
)
|
||||
mgr_d = BotSessionManager(
|
||||
platform="discord", identity_resolver=resolver, store=store
|
||||
)
|
||||
|
||||
out_t = await mgr_t.chat(
|
||||
agent, "tg-12345",
|
||||
"Remember this: my favourite colour is octarine.",
|
||||
)
|
||||
print("\n[Telegram] in: Remember this: my favourite colour is octarine.")
|
||||
print(f"[Telegram] out: {out_t}\n")
|
||||
|
||||
out_d = await mgr_d.chat(
|
||||
agent, "dc-67890",
|
||||
"What did I just tell you my favourite colour was?",
|
||||
)
|
||||
print("[Discord] in: What did I just tell you my favourite colour was?")
|
||||
print(f"[Discord] out: {out_d}\n")
|
||||
|
||||
if out_d and "octarine" in out_d.lower():
|
||||
print("PASS: Cross-platform context recalled.")
|
||||
return 0
|
||||
print("FAIL: Agent did not recall cross-platform fact.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""W1 deep-validation smoke test.
|
||||
|
||||
Exercises every robustness dimension of W1 with REAL LLM calls:
|
||||
|
||||
T1. 3-platform unification (telegram → discord → slack, one user)
|
||||
T2. Persistence across restart (FileIdentityResolver)
|
||||
T3. Concurrent users do NOT leak history
|
||||
T4. SessionContext is visible to a tool the agent calls
|
||||
T5. Self-improving probe — ask the agent to "learn a new skill"
|
||||
(this surfaces the W1↔W2 boundary honestly)
|
||||
|
||||
Run::
|
||||
|
||||
cd /Users/praison/worktrees/hermes-parity
|
||||
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_robust.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _pick_model() -> str:
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
return "anthropic/claude-haiku-4-5"
|
||||
if os.getenv("GOOGLE_API_KEY"):
|
||||
return "gemini/gemini-2.5-flash"
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T1 — 3-platform unification
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def test_three_platform_unification(tmp_path: Path, model: str) -> bool:
|
||||
print("\n" + "=" * 72)
|
||||
print("T1: 3-platform unification (telegram → discord → slack, same user)")
|
||||
print("=" * 72)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import (
|
||||
FileIdentityResolver,
|
||||
DefaultSessionStore,
|
||||
)
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
resolver = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
resolver.link("telegram", "tg-1", "alice")
|
||||
resolver.link("discord", "dc-1", "alice")
|
||||
resolver.link("slack", "sl-1", "alice")
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
agent = Agent(
|
||||
name="T1",
|
||||
instructions="Remember facts the user tells you and recall them precisely.",
|
||||
llm=model,
|
||||
)
|
||||
|
||||
mgr = lambda p: BotSessionManager(platform=p, identity_resolver=resolver, store=store)
|
||||
mgr_t, mgr_d, mgr_s = mgr("telegram"), mgr("discord"), mgr("slack")
|
||||
|
||||
out_t = await mgr_t.chat(agent, "tg-1", "My name is Vimes and my badge number is 177.")
|
||||
print(f"[Telegram] {out_t}")
|
||||
|
||||
out_d = await mgr_d.chat(agent, "dc-1", "What did I tell you my badge number was?")
|
||||
print(f"[Discord] {out_d}")
|
||||
|
||||
out_s = await mgr_s.chat(agent, "sl-1", "And what did I say my name was?")
|
||||
print(f"[Slack] {out_s}")
|
||||
|
||||
ok = (
|
||||
out_d and "177" in out_d
|
||||
and out_s and "vimes" in out_s.lower()
|
||||
)
|
||||
print(" RESULT:", "PASS" if ok else "FAIL")
|
||||
return bool(ok)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T2 — Persistence across restart
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def test_persistence_across_restart(tmp_path: Path, model: str) -> bool:
|
||||
print("\n" + "=" * 72)
|
||||
print("T2: Persistence — links + history survive 'restart' (new resolver+store)")
|
||||
print("=" * 72)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import (
|
||||
FileIdentityResolver,
|
||||
DefaultSessionStore,
|
||||
)
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
id_path = tmp_path / "identity.json"
|
||||
sess_dir = tmp_path / "sessions"
|
||||
|
||||
# ── First "process" ─────────────────────────────────────────────
|
||||
r1 = FileIdentityResolver(path=id_path)
|
||||
r1.link("telegram", "tg-2", "bob")
|
||||
s1 = DefaultSessionStore(session_dir=str(sess_dir))
|
||||
agent1 = Agent(name="T2", instructions="You remember facts.", llm=model)
|
||||
mgr1 = BotSessionManager(platform="telegram", identity_resolver=r1, store=s1)
|
||||
out1 = await mgr1.chat(
|
||||
agent1, "tg-2",
|
||||
"Remember: project name is 'pyramid-of-tsort'.",
|
||||
)
|
||||
print(f"[Run1 Telegram] {out1}")
|
||||
|
||||
# ── Second "process" — fresh objects, same files ────────────────
|
||||
r2 = FileIdentityResolver(path=id_path)
|
||||
s2 = DefaultSessionStore(session_dir=str(sess_dir))
|
||||
agent2 = Agent(name="T2", instructions="You remember facts.", llm=model)
|
||||
mgr2 = BotSessionManager(platform="telegram", identity_resolver=r2, store=s2)
|
||||
|
||||
# Link must have survived
|
||||
assert r2.resolve("telegram", "tg-2") == "bob", "FileIdentityResolver lost link!"
|
||||
|
||||
out2 = await mgr2.chat(agent2, "tg-2", "What was the project name I told you?")
|
||||
print(f"[Run2 Telegram] {out2}")
|
||||
|
||||
ok = bool(out2 and "pyramid" in out2.lower())
|
||||
print(" RESULT:", "PASS" if ok else "FAIL")
|
||||
return ok
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T3 — Concurrent users do NOT leak history
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def test_concurrent_users_isolated(tmp_path: Path, model: str) -> bool:
|
||||
print("\n" + "=" * 72)
|
||||
print("T3: Concurrent users — no history leakage")
|
||||
print("=" * 72)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import (
|
||||
FileIdentityResolver,
|
||||
DefaultSessionStore,
|
||||
)
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
resolver = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
resolver.link("telegram", "u-alice", "alice")
|
||||
resolver.link("telegram", "u-bob", "bob")
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
agent = Agent(name="T3", instructions="You remember facts per user.", llm=model)
|
||||
mgr = BotSessionManager(
|
||||
platform="telegram", identity_resolver=resolver, store=store
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
mgr.chat(agent, "u-alice", "I love mangoes."),
|
||||
mgr.chat(agent, "u-bob", "I love durian."),
|
||||
)
|
||||
out_alice = await mgr.chat(agent, "u-alice", "What fruit did I say I love?")
|
||||
out_bob = await mgr.chat(agent, "u-bob", "What fruit did I say I love?")
|
||||
print(f"[Alice] {out_alice}")
|
||||
print(f"[Bob] {out_bob}")
|
||||
|
||||
a_ok = out_alice and "mango" in out_alice.lower() and "durian" not in out_alice.lower()
|
||||
b_ok = out_bob and "durian" in out_bob.lower() and "mango" not in out_bob.lower()
|
||||
ok = bool(a_ok and b_ok)
|
||||
print(" RESULT:", "PASS" if ok else "FAIL")
|
||||
return ok
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T4 — SessionContext visible to a tool
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def test_session_context_visible_to_tool(tmp_path: Path, model: str) -> bool:
|
||||
print("\n" + "=" * 72)
|
||||
print("T4: SessionContext is visible to a tool the agent invokes")
|
||||
print("=" * 72)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import (
|
||||
FileIdentityResolver,
|
||||
DefaultSessionStore,
|
||||
get_session_context,
|
||||
)
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
captured = {}
|
||||
|
||||
def whoami() -> str:
|
||||
"""Return the platform and user id of the caller."""
|
||||
ctx = get_session_context()
|
||||
captured["platform"] = ctx.platform
|
||||
captured["unified_user_id"] = ctx.unified_user_id
|
||||
return f"platform={ctx.platform} user={ctx.unified_user_id}"
|
||||
|
||||
resolver = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
resolver.link("telegram", "tg-99", "alice")
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
|
||||
agent = Agent(
|
||||
name="T4",
|
||||
instructions=(
|
||||
"When the user asks 'who am I' or about platform info, "
|
||||
"ALWAYS call the whoami tool and report its result verbatim."
|
||||
),
|
||||
tools=[whoami],
|
||||
llm=model,
|
||||
)
|
||||
mgr = BotSessionManager(
|
||||
platform="telegram", identity_resolver=resolver, store=store
|
||||
)
|
||||
|
||||
out = await mgr.chat(
|
||||
agent, "tg-99",
|
||||
"Please call the whoami tool and tell me what you see.",
|
||||
chat_id="100", user_name="Alice",
|
||||
)
|
||||
print(f"[Agent] {out}")
|
||||
print(f"[Tool captured] platform={captured.get('platform')!r} "
|
||||
f"unified_user_id={captured.get('unified_user_id')!r}")
|
||||
|
||||
ok = (
|
||||
captured.get("platform") == "telegram"
|
||||
and captured.get("unified_user_id") == "alice"
|
||||
)
|
||||
print(" RESULT:", "PASS" if ok else "FAIL")
|
||||
return ok
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T5 — Self-improving probe (W1 ↔ W2 boundary)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def test_self_improving_probe(tmp_path: Path, model: str) -> bool:
|
||||
print("\n" + "=" * 72)
|
||||
print("T5: Self-improving probe — ask the agent to 'learn a new skill'")
|
||||
print(" (Expectation: W1 does NOT enable this — it is W2's job.)")
|
||||
print("=" * 72)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.session import FileIdentityResolver, DefaultSessionStore
|
||||
from praisonai.bots._session import BotSessionManager
|
||||
|
||||
resolver = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
resolver.link("telegram", "tg-77", "alice")
|
||||
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
|
||||
agent = Agent(name="T5", instructions="You are a helpful assistant.", llm=model)
|
||||
mgr = BotSessionManager(platform="telegram", identity_resolver=resolver, store=store)
|
||||
|
||||
out = await mgr.chat(
|
||||
agent, "tg-77",
|
||||
"I want you to learn a new skill called 'fizzbuzz_writer' that "
|
||||
"writes Python FizzBuzz. Add it to your permanent skills so a "
|
||||
"future session can use it. Then describe what you did.",
|
||||
)
|
||||
print(f"[Agent] {out}\n")
|
||||
|
||||
# Probe whether anything actually got persisted to disk as a skill.
|
||||
candidate_paths = [
|
||||
Path.home() / ".praisonai" / "skills",
|
||||
Path.cwd() / ".praison" / "skills",
|
||||
Path.cwd() / ".claude" / "skills",
|
||||
]
|
||||
found = []
|
||||
for p in candidate_paths:
|
||||
if p.exists():
|
||||
for child in p.glob("**/SKILL.md"):
|
||||
if "fizzbuzz" in child.read_text(errors="ignore").lower():
|
||||
found.append(str(child))
|
||||
|
||||
print(f"[Disk] SKILL.md files matching 'fizzbuzz': {found or 'none'}")
|
||||
print()
|
||||
print(" EXPECTED: W1 does NOT persist new skills — that's W2's curator loop.")
|
||||
print(" This test PASSES by demonstrating the gap, not by creating a file.")
|
||||
return True # Always pass — this is a documentation/probe test.
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
async def main() -> int:
|
||||
model = _pick_model()
|
||||
print(f"\n{'#' * 72}\n# W1 ROBUSTNESS SUITE — model: {model}\n{'#' * 72}")
|
||||
|
||||
results = {}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
for name, coro_factory in [
|
||||
("T1", lambda p: test_three_platform_unification(p, model)),
|
||||
("T2", lambda p: test_persistence_across_restart(p, model)),
|
||||
("T3", lambda p: test_concurrent_users_isolated(p, model)),
|
||||
("T4", lambda p: test_session_context_visible_to_tool(p, model)),
|
||||
("T5", lambda p: test_self_improving_probe(p, model)),
|
||||
]:
|
||||
sub = Path(td) / name
|
||||
sub.mkdir()
|
||||
try:
|
||||
results[name] = await coro_factory(sub)
|
||||
except Exception as e:
|
||||
print(f"\n EXCEPTION in {name}: {type(e).__name__}: {e}")
|
||||
results[name] = False
|
||||
|
||||
print(f"\n{'#' * 72}\n# SUMMARY\n{'#' * 72}")
|
||||
for name, ok in results.items():
|
||||
print(f" {name}: {'PASS' if ok else 'FAIL'}")
|
||||
return 0 if all(results.values()) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -519,6 +519,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model: Optional[Union[str, Any]] = None, # Alias for llm=
|
||||
base_url: Optional[str] = None, # Kept separate (connection/auth)
|
||||
api_key: Optional[str] = None, # Kept separate (connection/auth)
|
||||
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
|
||||
# Tools
|
||||
tools: Optional[List[Any]] = None,
|
||||
allow_delegation: bool = False, # Deprecated: use handoffs= instead
|
||||
@@ -1521,6 +1522,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_config['base_url'] = base_url
|
||||
if api_key:
|
||||
llm_config['api_key'] = api_key
|
||||
if auth:
|
||||
llm_config['auth'] = auth
|
||||
llm_config['metrics'] = metrics
|
||||
self.llm_instance = LLM(**llm_config)
|
||||
self.llm = llm.get('model', Agent._get_default_model())
|
||||
@@ -1531,6 +1534,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
auth=auth,
|
||||
metrics=metrics,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
@@ -1552,6 +1556,10 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
if api_key and 'api_key' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['api_key'] = api_key
|
||||
# Add auth if provided and not in dict
|
||||
if auth and 'auth' not in llm:
|
||||
llm = llm.copy()
|
||||
llm['auth'] = auth
|
||||
# Add metrics parameter
|
||||
llm = llm.copy()
|
||||
llm['metrics'] = metrics
|
||||
@@ -1571,6 +1579,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
|
||||
llm_params = {'model': llm}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import threading
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
@@ -45,9 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
import traceback
|
||||
from typing import List, Optional, Any, Dict, Union, Callable, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -560,7 +556,11 @@ Your Goal: {self.goal}"""
|
||||
temperature=temperature
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
|
||||
# C7 — honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
# Emit LLM request trace event (zero overhead when not set)
|
||||
@@ -1149,7 +1149,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -1193,11 +1193,17 @@ Your Goal: {self.goal}"""
|
||||
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
|
||||
|
||||
try:
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice)
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal chat implementation (extracted for trace wrapping)."""
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
@@ -1418,7 +1424,16 @@ Your Goal: {self.goal}"""
|
||||
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
|
||||
if effective_tool_choice:
|
||||
llm_kwargs['tool_choice'] = effective_tool_choice
|
||||
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
|
||||
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = self.llm_instance.get_response(**llm_kwargs)
|
||||
|
||||
self._add_to_chat_history("assistant", response_text)
|
||||
@@ -1714,7 +1729,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
cleaned = cleaned[:-3].strip()
|
||||
return cleaned
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None):
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -1737,13 +1752,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reasoning_steps=reasoning_steps, stream=stream,
|
||||
task_name=task_name, task_description=task_description, task_id=task_id,
|
||||
config=config, force_retrieval=force_retrieval, skip_retrieval=skip_retrieval,
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice
|
||||
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
|
||||
seed=seed, cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
_trace_emitter.agent_end(self.name)
|
||||
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
|
||||
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
|
||||
"""Internal async chat implementation (extracted for trace wrapping)."""
|
||||
# C2 — cooperative cancellation: abort early if a pre-set token is given
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
# Use agent's stream setting if not explicitly provided
|
||||
if stream is None:
|
||||
stream = self.stream
|
||||
@@ -1866,31 +1888,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
|
||||
|
||||
try:
|
||||
response_text = await self.llm_instance.get_response_async(
|
||||
prompt=prompt,
|
||||
system_prompt=self._build_system_prompt(tools),
|
||||
chat_history=self.chat_history,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
verbose=self.verbose,
|
||||
markdown=self.markdown,
|
||||
reflection=self.self_reflect,
|
||||
max_reflect=self.max_reflect,
|
||||
min_reflect=self.min_reflect,
|
||||
console=self.console,
|
||||
agent_name=self.name,
|
||||
agent_role=self.role,
|
||||
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
execute_tool_fn=self.execute_tool_async,
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream
|
||||
)
|
||||
# C1 — per-call seed forwarding (async path)
|
||||
llm_kwargs = {
|
||||
'prompt': prompt,
|
||||
'system_prompt': self._build_system_prompt(tools),
|
||||
'chat_history': self.chat_history,
|
||||
'temperature': temperature,
|
||||
'tools': tools,
|
||||
'output_json': output_json,
|
||||
'output_pydantic': output_pydantic,
|
||||
'verbose': self.verbose,
|
||||
'markdown': self.markdown,
|
||||
'reflection': self.self_reflect,
|
||||
'max_reflect': self.max_reflect,
|
||||
'min_reflect': self.min_reflect,
|
||||
'console': self.console,
|
||||
'agent_name': self.name,
|
||||
'agent_role': self.role,
|
||||
'agent_tools': [t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
|
||||
'task_name': task_name,
|
||||
'task_description': task_description,
|
||||
'task_id': task_id,
|
||||
'execute_tool_fn': self.execute_tool_async,
|
||||
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
'reasoning_steps': reasoning_steps,
|
||||
'stream': stream
|
||||
}
|
||||
|
||||
# C1 — per-call seed overrides llm_instance.seed for determinism
|
||||
if seed is not None:
|
||||
llm_kwargs['seed'] = seed
|
||||
|
||||
# C2 — last-chance cancel check before handing to the LLM
|
||||
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
|
||||
reason = getattr(_cancel, 'reason', None) or 'cancelled'
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
|
||||
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
|
||||
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ Contains all methods for run/start/launch lifecycle, planning,
|
||||
and server endpoints. Extracted from agent.py for maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import inspect
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
@@ -126,7 +124,7 @@ class ExecutionMixin:
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE),
|
||||
)
|
||||
|
||||
async def arun(self, prompt: str, **kwargs):
|
||||
async def arun(self, prompt: str, **kwargs) -> Optional[str]:
|
||||
"""Async version of run() - silent, non-streaming, returns structured result.
|
||||
|
||||
Production-friendly async execution. Does not stream or display output.
|
||||
|
||||
@@ -9,7 +9,6 @@ for maintainability.
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
import contextlib
|
||||
from typing import List, Optional, Any, Dict, Generator, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Generator, TYPE_CHECKING
|
||||
from collections import OrderedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -82,7 +81,7 @@ class MemoryMixin:
|
||||
with self._history_lock:
|
||||
self.chat_history.append({"role": role, "content": content})
|
||||
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content):
|
||||
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
|
||||
"""Thread-safe method to add messages to chat history only if not duplicate.
|
||||
|
||||
Atomically checks for duplicate and adds message under the same lock to prevent TOCTOU races.
|
||||
|
||||
@@ -194,6 +194,21 @@ class ToolExecutionMixin:
|
||||
if res.output and res.output.modified_data:
|
||||
arguments.update(res.output.modified_data)
|
||||
|
||||
# C4 — optional tool-argument validation via ToolValidatorProtocol.
|
||||
# Zero overhead when not set. Users wire via `agent._tool_validator = MyValidator()`.
|
||||
_validator = getattr(self, '_tool_validator', None)
|
||||
if _validator is not None:
|
||||
try:
|
||||
_vres = _validator.validate_args(function_name, arguments)
|
||||
if _vres is not None and not getattr(_vres, 'valid', True):
|
||||
_errs = "; ".join(getattr(_vres, 'errors', []) or ["validation failed"])
|
||||
logging.warning(
|
||||
f"Tool {function_name} args rejected by validator: {_errs}"
|
||||
)
|
||||
return f"Tool arguments rejected: {_errs}"
|
||||
except Exception as _ve: # noqa: BLE001 — never break tool exec on validator bug
|
||||
logging.debug(f"Tool validator raised; skipping validation: {_ve}")
|
||||
|
||||
# P8/G11: Apply tool timeout if configured
|
||||
tool_timeout = getattr(self, '_tool_timeout', None)
|
||||
if tool_timeout and tool_timeout > 0:
|
||||
@@ -758,11 +773,9 @@ class ToolExecutionMixin:
|
||||
pass
|
||||
|
||||
if func is None:
|
||||
# If not found in tools or registry, try globals and main
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
func = getattr(__main__, function_name, None)
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
pass
|
||||
|
||||
if func:
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,13 @@ Quick start::
|
||||
|
||||
async def authorize(self, identity, resource, action):
|
||||
return "owner" in identity.roles
|
||||
|
||||
Subscription auth (lazy-loaded)::
|
||||
|
||||
from praisonaiagents.auth import resolve_subscription_credentials
|
||||
|
||||
creds = resolve_subscription_credentials("claude-code")
|
||||
print(creds.api_key, creds.headers)
|
||||
"""
|
||||
|
||||
from .protocols import (
|
||||
@@ -25,7 +32,36 @@ from .protocols import (
|
||||
|
||||
__all__ = [
|
||||
"AuthIdentity",
|
||||
"AuthBackendProtocol",
|
||||
"AuthBackendProtocol",
|
||||
"WorkspaceContextProtocol",
|
||||
"AuthConfig",
|
||||
# Subscription auth (lazy-loaded)
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy-load subscription auth to avoid import overhead."""
|
||||
if name in (
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .subscription import (
|
||||
SubscriptionAuthProtocol,
|
||||
SubscriptionCredentials,
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Subscription / OAuth auth for LLM providers.
|
||||
|
||||
Opt-in, zero-overhead. Lazy-loaded via __getattr__ so importing
|
||||
praisonaiagents stays fast.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"SubscriptionAuthProtocol",
|
||||
"SubscriptionCredentials",
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("SubscriptionAuthProtocol", "SubscriptionCredentials"):
|
||||
from .protocols import SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
return locals()[name]
|
||||
if name in (
|
||||
"register_subscription_provider",
|
||||
"list_subscription_providers",
|
||||
"resolve_subscription_credentials",
|
||||
"get_subscription_provider",
|
||||
):
|
||||
from .registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
get_subscription_provider,
|
||||
)
|
||||
return locals()[name]
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Claude Code OAuth — read ~/.claude/.credentials.json or macOS Keychain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
|
||||
|
||||
|
||||
def _detect_claude_code_version() -> str:
|
||||
"""Detect the installed Claude Code version, fall back to a static constant.
|
||||
|
||||
Anthropic's OAuth infrastructure validates the user-agent version and may
|
||||
reject requests with a version that's too old. Detecting dynamically means
|
||||
users who keep Claude Code updated never hit stale-version 400s.
|
||||
"""
|
||||
cached = getattr(_detect_claude_code_version, "_cache", None)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["claude", "--version"], capture_output=True, text=True, timeout=2, check=False
|
||||
)
|
||||
match = re.match(r"(\d+\.\d+\.\d+)", out.stdout.strip())
|
||||
version = match.group(1) if match else _CLAUDE_CODE_VERSION_FALLBACK
|
||||
except Exception:
|
||||
version = _CLAUDE_CODE_VERSION_FALLBACK
|
||||
_detect_claude_code_version._cache = version
|
||||
return version
|
||||
|
||||
|
||||
_CLI_USER_AGENT_FALLBACK = f"claude-cli/{_CLAUDE_CODE_VERSION_FALLBACK} (external, cli)"
|
||||
|
||||
|
||||
def _read_keychain_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read 'Claude Code-credentials' entry from macOS Keychain.
|
||||
|
||||
Returns dict with accessToken/refreshToken/expiresAt or None.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
capture_output=True, text=True, timeout=5, check=False,
|
||||
)
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
return None
|
||||
data = json.loads(out.stdout)
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if oauth.get("accessToken"):
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-keychain",
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_file_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read ~/.claude/.credentials.json."""
|
||||
path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
oauth = data.get("claudeAiOauth") or {}
|
||||
if not oauth.get("accessToken"):
|
||||
return None
|
||||
return {
|
||||
"accessToken": oauth["accessToken"],
|
||||
"refreshToken": oauth.get("refreshToken", ""),
|
||||
"expiresAt": int(oauth.get("expiresAt", 0)),
|
||||
"source": "claude-code-file",
|
||||
}
|
||||
|
||||
|
||||
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
|
||||
if not expires_at_ms:
|
||||
return False
|
||||
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
|
||||
|
||||
|
||||
def _refresh(refresh_token: str) -> Dict[str, Any]:
|
||||
"""Pure refresh — does not touch local files."""
|
||||
if not refresh_token:
|
||||
raise AuthError("no refresh_token; please re-run 'claude /login'")
|
||||
body = urllib.parse.urlencode({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
}).encode()
|
||||
last = None
|
||||
for endpoint in _TOKEN_ENDPOINTS:
|
||||
req = urllib.request.Request(
|
||||
endpoint, data=body, method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
return {
|
||||
"accessToken": payload["access_token"],
|
||||
"refreshToken": payload.get("refresh_token", refresh_token),
|
||||
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
|
||||
}
|
||||
except Exception as exc:
|
||||
last = exc
|
||||
raise AuthError(f"Anthropic refresh failed: {last}")
|
||||
|
||||
|
||||
class ClaudeCodeAuth:
|
||||
"""Claude Code OAuth subscription auth."""
|
||||
|
||||
name = "claude-code"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# 1. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env (Hermes-style)
|
||||
for env_key in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
tok = os.environ.get(env_key, "").strip()
|
||||
if tok:
|
||||
return SubscriptionCredentials(
|
||||
api_key=tok,
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
source=f"env:{env_key}",
|
||||
)
|
||||
|
||||
# 2. Keychain or file
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds:
|
||||
raise AuthError(
|
||||
"No Claude Code credentials found. Install Claude Code and run "
|
||||
"'claude /login', or set ANTHROPIC_TOKEN."
|
||||
)
|
||||
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
|
||||
creds.update(_refresh(creds["refreshToken"]))
|
||||
return SubscriptionCredentials(
|
||||
api_key=creds["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=creds.get("expiresAt"),
|
||||
source=creds["source"],
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
creds = _read_keychain_credentials() or _read_file_credentials()
|
||||
if not creds or not creds.get("refreshToken"):
|
||||
raise AuthError("cannot refresh: no refresh token available")
|
||||
new = _refresh(creds["refreshToken"])
|
||||
return SubscriptionCredentials(
|
||||
api_key=new["accessToken"],
|
||||
base_url="https://api.anthropic.com",
|
||||
headers=self.headers_for("https://api.anthropic.com", ""),
|
||||
auth_scheme="bearer",
|
||||
expires_at_ms=new["expiresAt"],
|
||||
source="claude-code-refreshed",
|
||||
)
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
# CRITICAL: without these headers Anthropic returns 500s on OAuth tokens.
|
||||
# Match Hermes' OAuth header set exactly. litellm auto-adds oauth-2025-04-20.
|
||||
return {
|
||||
"anthropic-beta": ",".join([
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
"claude-code-20250219",
|
||||
]),
|
||||
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("claude-code", lambda: ClaudeCodeAuth())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Codex / ChatGPT subscription — read ~/.codex/auth.json (or CODEX_HOME)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def _read_codex_tokens() -> Optional[Dict[str, Any]]:
|
||||
home = os.environ.get("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
||||
auth_path = Path(home).expanduser() / "auth.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
tokens = payload.get("tokens") or {}
|
||||
if not tokens.get("access_token") or not tokens.get("refresh_token"):
|
||||
return None
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_jwt_expiring(jwt: str, skew_seconds: int = 60) -> bool:
|
||||
"""Decode JWT exp claim without verifying signature."""
|
||||
import base64
|
||||
try:
|
||||
_hdr, payload_b64, _sig = jwt.split(".")
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))
|
||||
exp = int(payload.get("exp", 0))
|
||||
return exp and (time.time() + skew_seconds) >= exp
|
||||
except Exception:
|
||||
return False # treat unknown as not-expiring; refresh on actual 401
|
||||
|
||||
|
||||
class CodexAuth:
|
||||
name = "codex"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Codex uses Responses API, not Chat Completions
|
||||
# OpenAI chat-completions requests will 404 against this endpoint
|
||||
raise AuthError(
|
||||
"Codex auth is experimental and requires custom Responses API transport. "
|
||||
"Use a standard OpenAI API key or wait for proper Codex support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# OpenAI Codex tokens are refreshed by the Codex CLI itself; we rely
|
||||
# on the CLI being run periodically. A direct refresh would require
|
||||
# replicating their device-flow client — out of scope for v1.
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"openai-beta": "responses=v1",
|
||||
"user-agent": "codex-cli/0.1.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("codex", lambda: CodexAuth())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Gemini CLI OAuth — read ~/.gemini/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_GEMINI_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
|
||||
|
||||
|
||||
def _read_gemini_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Gemini CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class GeminiCliAuth:
|
||||
name = "gemini-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
# EXPERIMENTAL: Gemini CLI uses Code Assist API, not OpenAI chat format
|
||||
# Standard OpenAI chat-completions requests will not work with cloudcode-pa endpoints
|
||||
raise AuthError(
|
||||
"Gemini CLI auth is experimental and requires custom Code Assist transport. "
|
||||
"Use standard Gemini API keys or wait for proper Code Assist support in a future release."
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Gemini CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "gemini-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("gemini-cli", lambda: GeminiCliAuth())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Subscription-auth protocol — what every provider must implement."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionCredentials:
|
||||
"""Resolved credentials ready to inject into an LLM client."""
|
||||
api_key: str # access_token (or "" for SDK-default chains like AWS)
|
||||
base_url: str = "" # provider endpoint
|
||||
headers: Dict[str, str] = field(default_factory=dict) # extra headers (UA, x-app, betas)
|
||||
auth_scheme: str = "bearer" # "bearer" | "x-api-key" | "sdk-default"
|
||||
expires_at_ms: Optional[int] = None # epoch ms; None = no expiry tracked
|
||||
source: str = "" # "claude-code-keychain", "codex-cli-file", etc.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SubscriptionAuthProtocol(Protocol):
|
||||
"""Discover, refresh, and inject subscription credentials."""
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
"""Return current valid credentials, refreshing if expiring soon.
|
||||
|
||||
Raises:
|
||||
AuthError: if no usable credentials can be resolved.
|
||||
"""
|
||||
...
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
"""Force a refresh and return the new credentials."""
|
||||
...
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
"""Provider-specific headers (UA, beta flags) keyed off endpoint/model."""
|
||||
...
|
||||
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
"""Raised when subscription credentials cannot be resolved."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Qwen CLI OAuth — read ~/.qwen/oauth_creds.json."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionCredentials
|
||||
from .registry import register_subscription_provider
|
||||
|
||||
|
||||
_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
|
||||
|
||||
def _read_qwen_tokens() -> Optional[Dict[str, Any]]:
|
||||
"""Read Qwen CLI OAuth credentials."""
|
||||
auth_path = Path.home() / ".qwen" / "oauth_creds.json"
|
||||
if not auth_path.is_file():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
if not payload.get("access_token"):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class QwenCliAuth:
|
||||
name = "qwen-cli"
|
||||
|
||||
def resolve_credentials(self) -> SubscriptionCredentials:
|
||||
tokens = _read_qwen_tokens()
|
||||
if not tokens:
|
||||
raise AuthError(
|
||||
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
|
||||
"Install Qwen CLI and run 'qwen login'."
|
||||
)
|
||||
return SubscriptionCredentials(
|
||||
api_key=tokens["access_token"],
|
||||
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
|
||||
headers=self.headers_for(_QWEN_BASE_URL, ""),
|
||||
auth_scheme="bearer",
|
||||
source="qwen-cli-file",
|
||||
)
|
||||
|
||||
def refresh(self) -> SubscriptionCredentials:
|
||||
# Qwen CLI handles refresh internally
|
||||
return self.resolve_credentials()
|
||||
|
||||
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
|
||||
return {
|
||||
"user-agent": "qwen-cli/1.0.0 (external)",
|
||||
}
|
||||
|
||||
|
||||
register_subscription_provider("qwen-cli", lambda: QwenCliAuth())
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Thread-safe provider registry for subscription auth."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
|
||||
|
||||
_REGISTRY: Dict[str, Callable[[], SubscriptionAuthProtocol]] = {}
|
||||
_LOCK = threading.RLock()
|
||||
_BUILTIN_REGISTERED = False
|
||||
|
||||
|
||||
def register_subscription_provider(
|
||||
provider_id: str,
|
||||
factory: Callable[[], SubscriptionAuthProtocol],
|
||||
) -> None:
|
||||
"""Register a new subscription-auth provider.
|
||||
|
||||
Args:
|
||||
provider_id: short id e.g. "claude-code", "codex", "gemini-cli".
|
||||
factory: zero-arg callable returning a SubscriptionAuthProtocol impl.
|
||||
"""
|
||||
with _LOCK:
|
||||
_REGISTRY[provider_id] = factory
|
||||
|
||||
|
||||
def list_subscription_providers() -> List[str]:
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
def resolve_subscription_credentials(provider_id: str) -> SubscriptionCredentials:
|
||||
"""Look up provider by id and resolve its credentials."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
if factory is None:
|
||||
raise AuthError(
|
||||
f"Unknown subscription provider {provider_id!r}. "
|
||||
f"Registered: {list_subscription_providers()}"
|
||||
)
|
||||
return factory().resolve_credentials()
|
||||
|
||||
|
||||
def get_subscription_provider(provider_id: str) -> Optional[SubscriptionAuthProtocol]:
|
||||
"""Return a new provider instance for *provider_id*, or None if not registered."""
|
||||
_ensure_builtins()
|
||||
with _LOCK:
|
||||
factory = _REGISTRY.get(provider_id)
|
||||
return factory() if factory is not None else None
|
||||
|
||||
|
||||
def _ensure_builtins() -> None:
|
||||
"""Lazy register built-in providers on first use."""
|
||||
global _BUILTIN_REGISTERED
|
||||
with _LOCK:
|
||||
if _BUILTIN_REGISTERED:
|
||||
return
|
||||
# Import modules inside the lock to prevent race conditions
|
||||
from . import claude_code, codex, gemini_cli, qwen_cli # noqa: F401
|
||||
# Each module registers itself at import time.
|
||||
_BUILTIN_REGISTERED = True
|
||||
@@ -28,6 +28,7 @@ from typing import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
|
||||
|
||||
@@ -758,3 +759,148 @@ def resolve_auth_mode(bind_host: str, configured: Optional[AuthMode] = None) ->
|
||||
return configured
|
||||
|
||||
return "local" if is_loopback(bind_host) else "token"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth, Pairing, and Session Binding Protocols (Issue #1588 Gap 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class AuthProtocol(Protocol):
|
||||
"""Protocol for authentication implementations.
|
||||
|
||||
Enables structural typing for different authentication strategies
|
||||
(token-based, local loopback, trusted proxy, etc.).
|
||||
"""
|
||||
|
||||
def check(self, request: Any) -> Dict[str, Any]:
|
||||
"""Check authentication for a request.
|
||||
|
||||
Args:
|
||||
request: The request object to authenticate
|
||||
|
||||
Returns:
|
||||
Authentication decision with metadata:
|
||||
- success: bool - whether authentication succeeded
|
||||
- user_id: Optional[str] - authenticated user ID
|
||||
- role: Optional[str] - user role/permissions
|
||||
- metadata: Dict[str, Any] - additional auth context
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PairingProtocol(Protocol):
|
||||
"""Protocol for channel pairing implementations.
|
||||
|
||||
Manages the authorization of external channels (Telegram, Slack, UI)
|
||||
to communicate with the gateway through signed codes.
|
||||
"""
|
||||
|
||||
def generate_code(
|
||||
self,
|
||||
channel_type: str = "unknown",
|
||||
channel_id: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a new pairing code for a channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel (e.g., "telegram", "slack", "ui")
|
||||
channel_id: Optional channel identifier
|
||||
|
||||
Returns:
|
||||
The generated pairing code
|
||||
"""
|
||||
...
|
||||
|
||||
def approve(
|
||||
self,
|
||||
channel_type: str,
|
||||
code: str,
|
||||
user_id: str = "",
|
||||
user_name: str = ""
|
||||
) -> bool:
|
||||
"""Approve a pairing code, authorizing the channel.
|
||||
|
||||
Args:
|
||||
channel_type: Type of channel
|
||||
code: The pairing code to approve
|
||||
user_id: User identifier (optional, defaults to empty string)
|
||||
user_name: Human-readable username (optional, defaults to empty string)
|
||||
|
||||
Returns:
|
||||
True if approval successful, False if code invalid/expired
|
||||
"""
|
||||
...
|
||||
|
||||
def is_paired(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Check if a channel is authorized.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if channel is paired/authorized
|
||||
"""
|
||||
...
|
||||
|
||||
def list_paired(self) -> List["PairedChannel"]:
|
||||
"""List all authorized channels.
|
||||
|
||||
Returns:
|
||||
List of paired channel information
|
||||
"""
|
||||
...
|
||||
|
||||
def revoke(self, channel_id: str, channel_type: str) -> bool:
|
||||
"""Revoke authorization for a channel.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
channel_type: Type of channel
|
||||
|
||||
Returns:
|
||||
True if revocation successful, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def list_pending(self, channel_type: Optional[str] = None) -> List[Dict[str, any]]:
|
||||
"""List pending pairing requests.
|
||||
|
||||
Args:
|
||||
channel_type: Optional filter by channel type
|
||||
|
||||
Returns:
|
||||
List of pending requests with channel, code, user info, and age
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionBindingProtocol(Protocol):
|
||||
"""Protocol for session binding implementations.
|
||||
|
||||
Manages the association between sessions and authenticated principals
|
||||
(users, agents, etc.) for state tracking and authorization.
|
||||
"""
|
||||
|
||||
def bind(self, session_id: str, principal: Dict[str, Any]) -> None:
|
||||
"""Bind a session to an authenticated principal.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
principal: Principal information (user_id, roles, metadata, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
def lookup(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up the principal bound to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier to look up
|
||||
|
||||
Returns:
|
||||
Principal information if found, None otherwise
|
||||
"""
|
||||
...
|
||||
@@ -71,7 +71,8 @@ class Mem0Adapter:
|
||||
from mem0 import Memory
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is not installed. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
if self._config:
|
||||
|
||||
@@ -361,6 +361,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
prompt_caching: Optional[bool] = None,
|
||||
claude_memory: Optional[Union[bool, Any]] = None,
|
||||
failover_manager: Optional[FailoverManagerProtocol] = None,
|
||||
auth: Optional[str] = None, # NEW: "claude-code", "codex", "gemini-cli", "qwen-cli"
|
||||
**extra_settings
|
||||
):
|
||||
# Configure logging only once at the class level
|
||||
@@ -437,6 +438,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._max_retries = extra_settings.get('max_retries', 3)
|
||||
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
|
||||
|
||||
# Subscription auth
|
||||
self._auth_provider_id = auth
|
||||
self._cached_subscription_creds = None # SubscriptionCredentials | None
|
||||
|
||||
# Failover management
|
||||
self._failover_manager = failover_manager
|
||||
self._current_profile = None # Track current auth profile for failover
|
||||
@@ -715,6 +720,35 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
logging.info(f"Failover: switching from {self.model} to {profile.model}")
|
||||
self.model = profile.model
|
||||
|
||||
def _resolve_subscription_creds(self):
|
||||
"""Lazy resolve + cache subscription credentials, checking expiration."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
|
||||
# Check if cached credentials are expired
|
||||
if (self._cached_subscription_creds and
|
||||
self._cached_subscription_creds.expires_at_ms and
|
||||
self._cached_subscription_creds.expires_at_ms <= int(time.time() * 1000)):
|
||||
self._cached_subscription_creds = None
|
||||
|
||||
if self._cached_subscription_creds is None:
|
||||
from ..auth import resolve_subscription_credentials
|
||||
self._cached_subscription_creds = resolve_subscription_credentials(
|
||||
self._auth_provider_id,
|
||||
)
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _refresh_subscription_creds(self):
|
||||
"""Force refresh of subscription credentials."""
|
||||
if not self._auth_provider_id:
|
||||
return None
|
||||
from ..auth.subscription.registry import get_subscription_provider
|
||||
provider = get_subscription_provider(self._auth_provider_id)
|
||||
if provider is None:
|
||||
return None
|
||||
self._cached_subscription_creds = provider.refresh()
|
||||
return self._cached_subscription_creds
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Call a function with automatic retry on rate limit errors and failover support.
|
||||
|
||||
@@ -751,6 +785,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -840,6 +889,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
last_error = e
|
||||
error_str = str(e)
|
||||
|
||||
# Check for auth errors and try refreshing subscription credentials
|
||||
if category == "auth" and self._auth_provider_id and attempt == 0:
|
||||
try:
|
||||
logging.info("Authentication error detected - attempting credential refresh")
|
||||
refreshed_creds = self._refresh_subscription_creds()
|
||||
if refreshed_creds:
|
||||
# Update parameters with refreshed credentials (don't clear cache)
|
||||
kwargs = self._build_completion_params(**kwargs)
|
||||
# Retry immediately with refreshed credentials
|
||||
can_retry = True
|
||||
retry_delay = 0.0
|
||||
logging.info("Subscription credentials refreshed, retrying...")
|
||||
except Exception as refresh_error:
|
||||
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
|
||||
|
||||
# Failover: mark failure and try next profile (do this before early exit)
|
||||
if self._failover_manager and self._current_profile:
|
||||
is_rate_limit = (category == "rate_limit")
|
||||
@@ -4593,6 +4657,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
filtered_extra_settings = {k: v for k, v in self.extra_settings.items() if k != 'metrics'}
|
||||
params.update(filtered_extra_settings)
|
||||
|
||||
# Inject subscription credentials if auth provider is set
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds:
|
||||
# Use litellm's native OAuth detection (auto-detects sk-ant-oat-* and switches to Bearer)
|
||||
params["api_key"] = creds.api_key
|
||||
if creds.base_url:
|
||||
params["base_url"] = creds.base_url
|
||||
if creds.headers:
|
||||
extra_headers = dict(params.get("extra_headers") or {})
|
||||
extra_headers.update(creds.headers)
|
||||
params["extra_headers"] = extra_headers
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
|
||||
import mem0
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
|
||||
"mem0ai is not installed. "
|
||||
"Run: pip install 'praisonaiagents[memory]'"
|
||||
)
|
||||
|
||||
return Mem0MemoryAdapter(mem0_config=kwargs)
|
||||
@@ -363,7 +364,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB short-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -371,7 +372,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "short_term"
|
||||
}
|
||||
|
||||
@@ -395,7 +396,7 @@ class MongoDBMemoryAdapter:
|
||||
|
||||
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
||||
"""Store in MongoDB long-term collection."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
doc_id = str(time.time_ns())
|
||||
@@ -403,7 +404,7 @@ class MongoDBMemoryAdapter:
|
||||
"_id": doc_id,
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(datetime.timezone.utc),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"memory_type": "long_term"
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter_name = provider_mapping.get(provider, provider)
|
||||
|
||||
# Try to get preferred adapter, fallback to available ones
|
||||
adapter = None
|
||||
provider_explicitly_requested = provider != "rag" # "rag" is default, others are explicit
|
||||
|
||||
try:
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = get_memory_adapter(adapter_name, **adapter_config)
|
||||
@@ -154,6 +157,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
|
||||
adapter = None
|
||||
|
||||
if adapter is None:
|
||||
# If the provider was explicitly requested, try to give a helpful error message
|
||||
if provider_explicitly_requested:
|
||||
try:
|
||||
# Try the factory function directly to get the specific ImportError message
|
||||
from .adapters.factories import (
|
||||
create_mem0_memory_adapter,
|
||||
create_chroma_memory_adapter,
|
||||
create_mongodb_memory_adapter,
|
||||
)
|
||||
factory_map = {
|
||||
"mem0": create_mem0_memory_adapter,
|
||||
"chroma": create_chroma_memory_adapter,
|
||||
"mongodb": create_mongodb_memory_adapter,
|
||||
}
|
||||
if adapter_name in factory_map:
|
||||
# Call the factory to get ImportError with installation hint
|
||||
# or capture successful adapter creation
|
||||
adapter_config = self._get_adapter_config_for_provider(adapter_name)
|
||||
adapter = factory_map[adapter_name](**adapter_config)
|
||||
except ImportError:
|
||||
# Re-raise ImportError with installation instructions
|
||||
raise
|
||||
# All other exceptions (e.g., ValueError for missing API key)
|
||||
# propagate naturally, preserving backward compatibility
|
||||
|
||||
# If factory succeeded, use that adapter
|
||||
if adapter is not None:
|
||||
self.memory_adapter = adapter
|
||||
self.provider = adapter_name
|
||||
return
|
||||
|
||||
# Fallback to first available adapter
|
||||
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
|
||||
# Try each fallback preference individually
|
||||
|
||||
@@ -90,7 +90,43 @@ def __getattr__(name: str):
|
||||
from .hierarchy import ExtendedSessionData
|
||||
_module_cache[name] = ExtendedSessionData
|
||||
return ExtendedSessionData
|
||||
|
||||
|
||||
# W1 — Identity resolver
|
||||
if name == "IdentityResolverProtocol":
|
||||
from .identity import IdentityResolverProtocol
|
||||
_module_cache[name] = IdentityResolverProtocol
|
||||
return IdentityResolverProtocol
|
||||
if name == "IdentityLink":
|
||||
from .identity import IdentityLink
|
||||
_module_cache[name] = IdentityLink
|
||||
return IdentityLink
|
||||
if name == "InMemoryIdentityResolver":
|
||||
from .identity import InMemoryIdentityResolver
|
||||
_module_cache[name] = InMemoryIdentityResolver
|
||||
return InMemoryIdentityResolver
|
||||
if name == "FileIdentityResolver":
|
||||
from .identity import FileIdentityResolver
|
||||
_module_cache[name] = FileIdentityResolver
|
||||
return FileIdentityResolver
|
||||
|
||||
# W1 — Task-local session context
|
||||
if name == "SessionContext":
|
||||
from .context import SessionContext
|
||||
_module_cache[name] = SessionContext
|
||||
return SessionContext
|
||||
if name == "set_session_context":
|
||||
from .context import set_session_context
|
||||
_module_cache[name] = set_session_context
|
||||
return set_session_context
|
||||
if name == "get_session_context":
|
||||
from .context import get_session_context
|
||||
_module_cache[name] = get_session_context
|
||||
return get_session_context
|
||||
if name == "clear_session_context":
|
||||
from .context import clear_session_context
|
||||
_module_cache[name] = clear_session_context
|
||||
return clear_session_context
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -105,4 +141,13 @@ __all__ = [
|
||||
"get_hierarchical_session_store",
|
||||
"SessionSnapshot",
|
||||
"ExtendedSessionData",
|
||||
# W1
|
||||
"IdentityResolverProtocol",
|
||||
"IdentityLink",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task-local session context for concurrent message handlers.
|
||||
|
||||
W1 — Replaces process-global ``os.environ`` based session metadata
|
||||
with ``contextvars.ContextVar`` so that two messages handled
|
||||
concurrently never overwrite each other's platform / chat / user IDs.
|
||||
|
||||
Each ``set_session_context`` call returns a token; pass it to
|
||||
``clear_session_context`` in a ``finally`` block to restore the
|
||||
previous state.
|
||||
|
||||
Usage in a bot handler::
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
set_session_context,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
)
|
||||
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
try:
|
||||
# ... agent handles message; tools can call get_session_context()
|
||||
ctx = get_session_context()
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Snapshot of the current message-handling context."""
|
||||
|
||||
platform: str = ""
|
||||
chat_id: str = ""
|
||||
chat_name: str = ""
|
||||
thread_id: str = ""
|
||||
user_id: str = ""
|
||||
user_name: str = ""
|
||||
unified_user_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "SessionContext":
|
||||
return cls(
|
||||
platform=d.get("platform", ""),
|
||||
chat_id=d.get("chat_id", ""),
|
||||
chat_name=d.get("chat_name", ""),
|
||||
thread_id=d.get("thread_id", ""),
|
||||
user_id=d.get("user_id", ""),
|
||||
user_name=d.get("user_name", ""),
|
||||
unified_user_id=d.get("unified_user_id", ""),
|
||||
)
|
||||
|
||||
|
||||
_CTX: ContextVar[SessionContext] = ContextVar(
|
||||
"praisonai_session_context", default=SessionContext()
|
||||
)
|
||||
|
||||
|
||||
def set_session_context(
|
||||
platform: str = "",
|
||||
chat_id: str = "",
|
||||
chat_name: str = "",
|
||||
thread_id: str = "",
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
unified_user_id: str = "",
|
||||
) -> Token:
|
||||
"""Set the task-local session context. Returns a token for ``clear``."""
|
||||
ctx = SessionContext(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_name,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
unified_user_id=unified_user_id,
|
||||
)
|
||||
return _CTX.set(ctx)
|
||||
|
||||
|
||||
def get_session_context() -> SessionContext:
|
||||
"""Return the current task's session context (empty if unset)."""
|
||||
return _CTX.get()
|
||||
|
||||
|
||||
def clear_session_context(token: Token) -> None:
|
||||
"""Restore the previous session context using the token from ``set``."""
|
||||
try:
|
||||
_CTX.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
# Token from a different context; reset to empty
|
||||
_CTX.set(SessionContext())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SessionContext",
|
||||
"set_session_context",
|
||||
"get_session_context",
|
||||
"clear_session_context",
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Cross-platform identity resolution for agents.
|
||||
|
||||
W1 — Optional, opt-in mapping from ``(platform, platform_user_id)``
|
||||
to a single ``unified_user_id`` so the same human is recognised across
|
||||
Telegram, Discord, Slack, etc.
|
||||
|
||||
By default (no links registered) ``resolve()`` returns the
|
||||
platform-prefixed string unchanged — no surprises, no privacy leak.
|
||||
|
||||
Linking is performed explicitly, typically after a DM-based pairing
|
||||
flow has cryptographically verified ownership of both accounts.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("telegram", "12345", "alice")
|
||||
resolver.link("discord", "snowflake-1", "alice")
|
||||
assert resolver.resolve("telegram", "12345") == "alice"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityLink:
|
||||
"""Immutable mapping from a platform-scoped user ID to a unified ID."""
|
||||
|
||||
platform: str
|
||||
platform_user_id: str
|
||||
unified_user_id: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityResolverProtocol(Protocol):
|
||||
"""Maps platform-scoped user IDs to a unified identity.
|
||||
|
||||
Implementations MUST be thread-safe. Implementations SHOULD return
|
||||
``f"{platform}:{platform_user_id}"`` for unlinked users so callers
|
||||
never need to special-case the unlinked path.
|
||||
"""
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
"""Return ``unified_user_id`` for the given platform user.
|
||||
|
||||
If no link is registered, return ``f"{platform}:{platform_user_id}"``.
|
||||
"""
|
||||
...
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
"""Register a mapping. Overwrites any existing mapping for the same
|
||||
``(platform, platform_user_id)`` pair.
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
"""Remove a mapping. No-op when no link exists."""
|
||||
...
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
"""Return all links pointing to the given unified ID."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdentityResolver:
|
||||
"""Thread-safe in-memory ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Suitable for tests and single-process deployments. Production
|
||||
deployments with multiple processes should use a persistent
|
||||
implementation backed by SQLite, Redis, or a database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._links: dict[tuple[str, str], str] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def resolve(self, platform: str, platform_user_id: str) -> str:
|
||||
with self._lock:
|
||||
return self._links.get(
|
||||
(platform, platform_user_id),
|
||||
f"{platform}:{platform_user_id}",
|
||||
)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._links[(platform, platform_user_id)] = unified_user_id
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
with self._lock:
|
||||
self._links.pop((platform, platform_user_id), None)
|
||||
|
||||
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
|
||||
with self._lock:
|
||||
return [
|
||||
IdentityLink(p, u, unified_user_id)
|
||||
for (p, u), uid in self._links.items()
|
||||
if uid == unified_user_id
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_IDENTITY_PATH = Path(
|
||||
os.environ.get(
|
||||
"PRAISONAI_IDENTITY_PATH",
|
||||
os.path.expanduser("~/.praisonai/identity.json"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FileIdentityResolver(InMemoryIdentityResolver):
|
||||
"""JSON-file-backed ``IdentityResolverProtocol`` implementation.
|
||||
|
||||
Loads links from disk on construction; persists on every mutation
|
||||
via atomic temp-file + ``os.replace``. Thread-safe.
|
||||
|
||||
Default path: ``$PRAISONAI_IDENTITY_PATH`` or ``~/.praisonai/identity.json``.
|
||||
|
||||
Storage format::
|
||||
|
||||
{
|
||||
"links": {
|
||||
"telegram::12345": "alice",
|
||||
"discord::snowflake-1": "alice"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Optional[Path | str] = None) -> None:
|
||||
super().__init__()
|
||||
self._path: Path = Path(path) if path else _DEFAULT_IDENTITY_PATH
|
||||
# Serialises concurrent disk writes so that two simultaneous
|
||||
# link()/unlink() calls cannot interleave their os.replace() and
|
||||
# lose the second writer's data.
|
||||
self._flush_lock = threading.Lock()
|
||||
self._load()
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(platform: str, platform_user_id: str) -> str:
|
||||
return f"{platform}::{platform_user_id}"
|
||||
|
||||
@staticmethod
|
||||
def _decode_key(encoded: str) -> tuple[str, str]:
|
||||
platform, _, user = encoded.partition("::")
|
||||
return platform, user
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if not self._path.exists():
|
||||
return
|
||||
data = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
links = data.get("links", {}) if isinstance(data, dict) else {}
|
||||
with self._lock:
|
||||
self._links = {
|
||||
self._decode_key(k): v
|
||||
for k, v in links.items()
|
||||
if isinstance(v, str)
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("FileIdentityResolver: failed to load %s: %s", self._path, e)
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Hold _flush_lock for the entire write (snapshot + disk I/O) so
|
||||
# that concurrent calls always write the latest in-memory state and
|
||||
# the last os.replace() wins rather than an older snapshot.
|
||||
with self._flush_lock:
|
||||
with self._lock:
|
||||
payload = {
|
||||
"links": {
|
||||
self._encode_key(p, u): uid
|
||||
for (p, u), uid in self._links.items()
|
||||
}
|
||||
}
|
||||
# Keep the _lock held across the entire write operation to prevent
|
||||
# TOCTOU race where a concurrent link() could modify _links between
|
||||
# payload snapshot and os.replace() completion.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self._path.parent), prefix=".identity-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
try:
|
||||
os.chmod(self._path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as e:
|
||||
logger.warning("FileIdentityResolver: failed to flush %s: %s", self._path, e)
|
||||
|
||||
def link(
|
||||
self, platform: str, platform_user_id: str, unified_user_id: str
|
||||
) -> None:
|
||||
super().link(platform, platform_user_id, unified_user_id)
|
||||
self._flush()
|
||||
|
||||
def unlink(self, platform: str, platform_user_id: str) -> None:
|
||||
super().unlink(platform, platform_user_id)
|
||||
self._flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityLink",
|
||||
"IdentityResolverProtocol",
|
||||
"InMemoryIdentityResolver",
|
||||
"FileIdentityResolver",
|
||||
]
|
||||
@@ -45,6 +45,10 @@ __all__ = [
|
||||
# Redaction
|
||||
"redact_dict",
|
||||
"REDACT_KEYS",
|
||||
# C7 — PII redaction for LLM egress
|
||||
"scrub_pii_text",
|
||||
"enable_pii_redaction",
|
||||
"disable_pii_redaction",
|
||||
# Context events (for replay)
|
||||
"ContextEvent",
|
||||
"ContextEventType",
|
||||
@@ -79,6 +83,10 @@ def __getattr__(name: str):
|
||||
if name in ("redact_dict", "REDACT_KEYS"):
|
||||
from .redact import redact_dict, REDACT_KEYS
|
||||
return locals()[name]
|
||||
|
||||
if name in ("scrub_pii_text", "enable_pii_redaction", "disable_pii_redaction"):
|
||||
from .redact import scrub_pii_text, enable_pii_redaction, disable_pii_redaction
|
||||
return locals()[name]
|
||||
|
||||
# Context events (for replay)
|
||||
if name in ("ContextEvent", "ContextEventType"):
|
||||
|
||||
@@ -5,8 +5,9 @@ Provides deterministic redaction of sensitive data in tool arguments
|
||||
and results. Uses only stdlib for zero dependencies.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Set
|
||||
from typing import Any, Dict, Optional, Set
|
||||
import re
|
||||
import threading
|
||||
|
||||
# Keys that should always be redacted (case-insensitive matching)
|
||||
REDACT_KEYS: Set[str] = {
|
||||
@@ -119,6 +120,109 @@ def _redact_key_value(key: str, value: Any) -> Any:
|
||||
return _redact_value(value)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — PII redaction for LLM egress (opt-in BEFORE_LLM middleware)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Additional value-pattern rules for strings that look like secrets
|
||||
# even when no key=value pair surrounds them.
|
||||
_VALUE_PATTERNS = (
|
||||
# OpenAI-style API keys
|
||||
(re.compile(r"\bsk-[A-Za-z0-9]{12,}\b"), "[REDACTED]"),
|
||||
# US SSN
|
||||
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED-SSN]"),
|
||||
# Credit card — canonical 4-group format only (e.g. "4111 1111 1111 1111").
|
||||
# @claude: The bare \b\d{16}\b branch was removed — it matches ANY 16-digit sequence:
|
||||
# UNIX microsecond timestamps, distributed trace IDs, order/invoice numbers, phone
|
||||
# numbers — causing false-positive redactions in LLM tool outputs and RAG results.
|
||||
# Unformatted 16-digit strings are indistinguishable from non-card IDs at regex level.
|
||||
# The 4-group pattern covers real-world formatted card numbers with near-zero FP rate.
|
||||
(re.compile(r"\b(?:\d{4}[ -]){3}\d{4}\b"), "[REDACTED-CC]"),
|
||||
# Email (optional — often safe, but default-scrub for compliance)
|
||||
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[REDACTED-EMAIL]"),
|
||||
)
|
||||
|
||||
|
||||
def scrub_pii_text(text: str) -> str:
|
||||
"""Redact API keys, passwords, SSNs, credit cards, and emails from free text.
|
||||
|
||||
Reuses REDACT_KEYS for ``key=value`` pairs and adds value-pattern rules
|
||||
for naked secrets. Zero dependency (stdlib re only).
|
||||
|
||||
Args:
|
||||
text: Input string — returned unchanged if empty or None.
|
||||
|
||||
Returns:
|
||||
Scrubbed string with literal secrets replaced by ``[REDACTED*]``.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
result = redact_string(text, enabled=True)
|
||||
for pat, repl in _VALUE_PATTERNS:
|
||||
result = pat.sub(repl, result)
|
||||
return result
|
||||
|
||||
|
||||
# Module-level state for idempotent enable/disable
|
||||
_PII_HOOK_ID: Optional[str] = None
|
||||
_PII_HOOK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _pii_before_llm_hook(event_data):
|
||||
"""BEFORE_LLM hook that scrubs every message's content in-place."""
|
||||
try:
|
||||
messages = getattr(event_data, "messages", None) or []
|
||||
modified = []
|
||||
for m in messages:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
m = {**m, "content": scrub_pii_text(m["content"])}
|
||||
modified.append(m)
|
||||
# Import locally to avoid module-level hooks dep (keeps redact.py lightweight)
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow", modified_input={"messages": modified})
|
||||
except Exception:
|
||||
# Never block the LLM call on a scrubber bug
|
||||
from ..hooks.types import HookResult
|
||||
return HookResult(decision="allow")
|
||||
|
||||
|
||||
def enable_pii_redaction() -> str:
|
||||
"""Register a BEFORE_LLM hook that scrubs secrets from every message.
|
||||
|
||||
Idempotent — calling twice leaves exactly one hook registered.
|
||||
|
||||
Returns:
|
||||
The hook id (useful for :func:`disable_pii_redaction`).
|
||||
"""
|
||||
global _PII_HOOK_ID
|
||||
with _PII_HOOK_LOCK:
|
||||
if _PII_HOOK_ID is not None:
|
||||
return _PII_HOOK_ID
|
||||
from ..hooks.registry import get_default_registry
|
||||
from ..hooks.types import HookEvent
|
||||
reg = get_default_registry()
|
||||
_PII_HOOK_ID = reg.register_function(
|
||||
event=HookEvent.BEFORE_LLM,
|
||||
func=_pii_before_llm_hook,
|
||||
name="praisonaiagents.pii_redactor",
|
||||
)
|
||||
return _PII_HOOK_ID
|
||||
|
||||
|
||||
def disable_pii_redaction() -> bool:
|
||||
"""Unregister the PII-redaction hook. No-op if never enabled."""
|
||||
global _PII_HOOK_ID
|
||||
if _PII_HOOK_ID is None:
|
||||
return False
|
||||
from ..hooks.registry import get_default_registry
|
||||
reg = get_default_registry()
|
||||
try:
|
||||
reg.unregister(_PII_HOOK_ID)
|
||||
finally:
|
||||
_PII_HOOK_ID = None
|
||||
return True
|
||||
|
||||
|
||||
def redact_string(text: str, enabled: bool = True) -> str:
|
||||
"""
|
||||
Redact potential secrets from a string.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.32"
|
||||
version = "1.6.37"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,7 +30,7 @@ mcp = [
|
||||
|
||||
memory = [
|
||||
"chromadb>=1.0.0",
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
]
|
||||
|
||||
knowledge = [
|
||||
@@ -48,7 +48,7 @@ graph = [
|
||||
|
||||
# Add LLM dependencies
|
||||
llm = [
|
||||
"litellm>=1.81.0,<=1.82.6",
|
||||
"litellm>=1.83.14",
|
||||
"pydantic>=2.10.0"
|
||||
]
|
||||
|
||||
@@ -113,7 +113,9 @@ all = [
|
||||
"praisonaiagents[mongodb]",
|
||||
"praisonaiagents[auth]",
|
||||
"praisonaiagents[search]",
|
||||
"praisonaiagents[crawl]"
|
||||
"praisonaiagents[crawl]",
|
||||
"praisonaiagents[autonomy]",
|
||||
"praisonaiagents[os]"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate subscription auth fixes without pytest dependency.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add current directory to Python path
|
||||
sys.path.insert(0, '.')
|
||||
sys.path.insert(0, os.path.join(os.getcwd()))
|
||||
|
||||
def test_imports():
|
||||
"""Test that imports work correctly."""
|
||||
print("Testing imports...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
print("✅ Protocol imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
print("✅ Claude Code auth imports work")
|
||||
|
||||
from praisonaiagents.auth.subscription.registry import resolve_subscription_credentials
|
||||
print("✅ Registry imports work")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Import test failed: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_codex_experimental():
|
||||
"""Test that Codex raises experimental error."""
|
||||
print("\nTesting Codex experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.codex import CodexAuth
|
||||
auth = CodexAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Codex should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Codex correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Codex raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Codex test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_gemini_experimental():
|
||||
"""Test that Gemini CLI raises experimental error."""
|
||||
print("\nTesting Gemini CLI experimental error...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.gemini_cli import GeminiCliAuth
|
||||
auth = GeminiCliAuth()
|
||||
try:
|
||||
auth.resolve_credentials()
|
||||
print("❌ Gemini CLI should have raised experimental error")
|
||||
return False
|
||||
except Exception as e:
|
||||
if "experimental" in str(e).lower():
|
||||
print("✅ Gemini CLI correctly raises experimental error")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Gemini CLI raised wrong error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Gemini CLI test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_header_simplification():
|
||||
"""Test that Claude Code headers are simplified."""
|
||||
print("\nTesting Claude Code header simplification...")
|
||||
try:
|
||||
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
# Should only have interleaved-thinking, not oauth header (litellm handles that)
|
||||
if "anthropic-beta" in headers:
|
||||
beta_header = headers["anthropic-beta"]
|
||||
if beta_header == "interleaved-thinking-2025-05-14":
|
||||
print("✅ Claude Code headers correctly simplified")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Unexpected beta header value: {beta_header}")
|
||||
return False
|
||||
else:
|
||||
print("❌ anthropic-beta header missing")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Header test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_agent_construction():
|
||||
"""Test basic agent construction with auth parameter."""
|
||||
print("\nTesting Agent construction with auth...")
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# This should work (though credential resolution will fail without actual creds)
|
||||
agent = Agent(name="test", auth="claude-code")
|
||||
print("✅ Agent construction with auth parameter works")
|
||||
|
||||
# Test invalid provider
|
||||
try:
|
||||
agent = Agent(name="test", auth="invalid-provider")
|
||||
print("⚠️ Invalid provider didn't fail at construction (will fail at runtime)")
|
||||
except Exception:
|
||||
print("✅ Invalid provider handling works")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent construction test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all validation tests."""
|
||||
print("=== Validating Subscription Auth Fixes ===\n")
|
||||
|
||||
tests = [
|
||||
test_imports,
|
||||
test_codex_experimental,
|
||||
test_gemini_experimental,
|
||||
test_header_simplification,
|
||||
test_agent_construction,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
total = len(tests)
|
||||
|
||||
for test in tests:
|
||||
if test():
|
||||
passed += 1
|
||||
else:
|
||||
print()
|
||||
|
||||
print(f"\n=== Results: {passed}/{total} tests passed ===")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 All validation tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("💥 Some tests failed!")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Integration tests for Agent with subscription auth."""
|
||||
import pytest
|
||||
|
||||
# Skip entire module when LLM dependencies (e.g. pydantic) are not installed.
|
||||
# CI runs without the [llm] extra, so these are skipped there.
|
||||
pytest.importorskip("pydantic", reason="requires praisonaiagents[llm]")
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subscription_auth():
|
||||
"""Mock subscription auth that returns test credentials."""
|
||||
def mock_resolve_credentials(provider_id):
|
||||
if provider_id == "claude-code":
|
||||
return SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-stub",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={
|
||||
"anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||
"user-agent": "claude-cli/2.1.0 (external, cli)",
|
||||
"x-app": "cli",
|
||||
},
|
||||
auth_scheme="bearer",
|
||||
source="test-mock"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unknown provider: {provider_id}")
|
||||
|
||||
return mock_resolve_credentials
|
||||
|
||||
|
||||
def test_agent_accepts_auth_kwarg(mock_subscription_auth):
|
||||
"""Test that Agent accepts auth parameter and passes it to LLM."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
llm="anthropic/claude-3-haiku-20240307",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
assert hasattr(agent, 'llm_instance')
|
||||
assert agent.llm_instance._auth_provider_id == "claude-code"
|
||||
|
||||
|
||||
def test_llm_kwargs_carry_oauth_headers(mock_subscription_auth):
|
||||
"""Test that LLM completion params include OAuth headers."""
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
|
||||
from praisonaiagents.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-haiku-20240307", auth="claude-code")
|
||||
params = llm._build_completion_params()
|
||||
|
||||
assert params["api_key"] == "sk-ant-oat-stub"
|
||||
assert params["base_url"] == "https://api.anthropic.com"
|
||||
assert params["extra_headers"]["x-app"] == "cli"
|
||||
assert params["extra_headers"]["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
|
||||
|
||||
def test_agent_without_auth_works_normally():
|
||||
"""Test that Agent without auth parameter works as before."""
|
||||
agent = Agent(name="test-agent", llm="anthropic/claude-3-haiku-20240307")
|
||||
|
||||
if hasattr(agent, 'llm_instance'):
|
||||
assert agent.llm_instance._auth_provider_id is None
|
||||
assert agent.llm_instance._cached_subscription_creds is None
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Smoke test for subscription authentication - exercises real auth flows.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
pytest.skip("litellm not available", allow_module_level=True)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
|
||||
def test_claude_code_auth_flow():
|
||||
"""Test that Claude Code auth properly injects OAuth credentials."""
|
||||
# Mock litellm completion to capture the actual params sent
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Hello! This is a test response."
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
mock_completion.return_value = mock_response
|
||||
|
||||
# Mock the credential resolution to return OAuth token
|
||||
mock_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-test-oauth-token", # OAuth token format
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={"user-agent": "claude-cli/2.1.0", "x-app": "cli"},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = mock_creds
|
||||
|
||||
# Create agent with Claude Code auth
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="You are a test assistant.",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# Make a real request
|
||||
response = agent.start("Say hello")
|
||||
|
||||
# Verify the call was made
|
||||
assert mock_completion.called
|
||||
call_args = mock_completion.call_args
|
||||
params = call_args.kwargs if call_args.kwargs else {}
|
||||
|
||||
# Verify OAuth token was passed as api_key (litellm will auto-detect and use Bearer)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
assert params.get("base_url") == "https://api.anthropic.com"
|
||||
|
||||
# Verify provider headers are included
|
||||
extra_headers = params.get("extra_headers", {})
|
||||
assert "user-agent" in extra_headers
|
||||
assert "x-app" in extra_headers
|
||||
|
||||
# Verify we got a response (just check that completion was called with right params)
|
||||
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
|
||||
|
||||
|
||||
def test_auth_refresh_on_401():
|
||||
"""Test that auth errors trigger credential refresh and retry."""
|
||||
# First call: return 401, second call: success
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.content = "Success after refresh"
|
||||
|
||||
auth_error = Exception("AuthenticationError: Invalid API key")
|
||||
|
||||
with patch('litellm.completion') as mock_completion:
|
||||
# First call raises auth error, second succeeds
|
||||
mock_completion.side_effect = [auth_error, mock_response]
|
||||
|
||||
# Mock credential resolution and refresh
|
||||
initial_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-expired-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-test"
|
||||
)
|
||||
|
||||
refreshed_creds = SubscriptionCredentials(
|
||||
api_key="sk-ant-oat-fresh-token",
|
||||
base_url="https://api.anthropic.com",
|
||||
headers={},
|
||||
auth_scheme="bearer",
|
||||
source="claude-code-refreshed"
|
||||
)
|
||||
|
||||
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
|
||||
mock_resolve.return_value = initial_creds
|
||||
|
||||
with patch('praisonaiagents.auth.subscription.registry.get_subscription_provider') as mock_provider:
|
||||
mock_auth_provider = Mock()
|
||||
mock_auth_provider.refresh.return_value = refreshed_creds
|
||||
mock_provider.return_value = mock_auth_provider
|
||||
|
||||
# Mock error classification to detect auth error
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
with patch.object(LLM, '_classify_error_and_should_retry') as mock_classify:
|
||||
# First call: auth error, can retry
|
||||
# Second call: shouldn't be called since retry succeeds
|
||||
mock_classify.return_value = ("auth", True, 0.0)
|
||||
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
instructions="Test agent",
|
||||
auth="claude-code"
|
||||
)
|
||||
|
||||
# This should trigger refresh on first 401 and succeed on retry
|
||||
response = agent.start("Test refresh flow")
|
||||
|
||||
# Verify refresh was called
|
||||
assert mock_auth_provider.refresh.called
|
||||
|
||||
# Verify we got success response
|
||||
assert "Success after refresh" in response
|
||||
|
||||
|
||||
def test_invalid_auth_provider():
|
||||
"""Test that invalid auth providers raise clear errors."""
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
agent = Agent(
|
||||
name="test-agent",
|
||||
auth="nonexistent-provider"
|
||||
)
|
||||
agent.start("This should fail")
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "unknown" in error_msg or "provider" in error_msg
|
||||
|
||||
|
||||
def test_codex_experimental_error():
|
||||
"""Test that Codex auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="codex")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_gemini_experimental_error():
|
||||
"""Test that Gemini CLI auth raises experimental error."""
|
||||
from praisonaiagents.auth.subscription.protocols import AuthError
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
agent = Agent(name="test", auth="gemini-cli")
|
||||
agent.start("test")
|
||||
assert "experimental" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Manual test runner for development
|
||||
test_claude_code_auth_flow()
|
||||
test_auth_refresh_on_401()
|
||||
test_invalid_auth_provider()
|
||||
test_codex_experimental_error()
|
||||
test_gemini_experimental_error()
|
||||
print("✅ All subscription auth smoke tests passed!")
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real agentic E2E — Phase A (C1/C2/C4/C7).
|
||||
|
||||
Exercises all four gap closures in a single live LLM run:
|
||||
|
||||
* C1 seed — request deterministic output
|
||||
* C2 cancel — pass a non-tripped InterruptController
|
||||
* C4 validator — install a passthrough ToolValidator (asserts it was invoked)
|
||||
* C7 PII — enable_pii_redaction() so secrets in the prompt are scrubbed
|
||||
|
||||
Run::
|
||||
|
||||
ANTHROPIC_API_KEY=... python tests/smoke_core_phase_a_real.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _pick_model() -> str:
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
return "anthropic/claude-haiku-4-5"
|
||||
if os.getenv("GOOGLE_API_KEY"):
|
||||
return "gemini/gemini-2.5-flash"
|
||||
return "gpt-4o-mini"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
from praisonaiagents.tools.validators import ValidationResult
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
|
||||
model = _pick_model()
|
||||
print(f"Model: {model}\n")
|
||||
|
||||
# C7 — register the PII scrubber for the whole run
|
||||
enable_pii_redaction()
|
||||
|
||||
# A trivial tool the LLM may or may not call — mainly to exercise the
|
||||
# validator wiring. We track how often it is asked to validate.
|
||||
validated: list[str] = []
|
||||
|
||||
class LoggingValidator:
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
validated.append(tool_name)
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def echo(text: str) -> str:
|
||||
"""Echo a string back."""
|
||||
return f"echoed: {text}"
|
||||
|
||||
agent = Agent(
|
||||
name="PhaseATester",
|
||||
instructions=(
|
||||
"You are a concise assistant. Answer with one short sentence. "
|
||||
"Feel free to call the echo tool if asked."
|
||||
),
|
||||
llm={"model": model},
|
||||
tools=[echo],
|
||||
)
|
||||
agent._tool_validator = LoggingValidator() # C4 wiring
|
||||
|
||||
tok = InterruptController() # C2 — not set, must not affect anything
|
||||
|
||||
# Prompt contains a fake secret — C7 must scrub it before LLM egress.
|
||||
prompt = (
|
||||
"My api_key=sk-XYZSECRET12345 — please answer: what is 2 + 2? "
|
||||
"Reply with a single digit."
|
||||
)
|
||||
|
||||
print(f"[prompt raw] {prompt}")
|
||||
reply = agent.chat(prompt, seed=42, cancel_token=tok)
|
||||
print(f"[reply] {reply}\n")
|
||||
|
||||
# Validate outcomes
|
||||
ok = True
|
||||
|
||||
if not reply:
|
||||
print("FAIL: LLM returned empty reply")
|
||||
ok = False
|
||||
elif "4" not in reply:
|
||||
print("FAIL: reply does not contain expected '4'")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C1 — seed threaded and LLM returned a valid reply")
|
||||
|
||||
# Confirm the scrubber registered its hook (C7)
|
||||
# (chat_history stores the user's raw prompt by design — scrubbing happens
|
||||
# only on egress to the LLM, verified here via hook registry state.)
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
has_pii = any(
|
||||
(getattr(h, "name", "") or "").startswith("praisonaiagents.pii_redactor")
|
||||
for ev in getattr(reg, "_hooks", {}).values()
|
||||
for h in (ev if isinstance(ev, list) else [])
|
||||
)
|
||||
if has_pii:
|
||||
print("PASS C7 — PII redactor hook active during LLM call")
|
||||
else:
|
||||
print("FAIL C7 — PII redactor hook missing from registry")
|
||||
ok = False
|
||||
|
||||
# Cancel-token path did not trip (C2)
|
||||
if tok.is_set():
|
||||
print("FAIL C2 — cancel token unexpectedly set")
|
||||
ok = False
|
||||
else:
|
||||
print("PASS C2 — cancel_token honoured without tripping")
|
||||
|
||||
# Validator invocation (C4) is exercised only if LLM actually called echo;
|
||||
# don't fail the run if it chose not to call a tool.
|
||||
if validated:
|
||||
print(f"PASS C4 — validator invoked for: {validated}")
|
||||
else:
|
||||
print("SKIP C4 — LLM chose not to call a tool this run (non-fatal)")
|
||||
|
||||
disable_pii_redaction()
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Phase A gap-closure tests: C1 seed, C2 cancel_token, C4 tool validator, C7 PII.
|
||||
|
||||
These tests exercise the four gap closures identified in the core-features gap
|
||||
analysis. They target the existing infrastructure (InterruptController,
|
||||
ToolValidatorProtocol, trace/redact rules) which was defined but not wired into
|
||||
`Agent.chat()` / tool execution paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.validators import (
|
||||
ToolValidatorProtocol,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C1 — seed threaded through Agent.chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1SeedPassthrough:
|
||||
def test_chat_signature_accepts_seed(self):
|
||||
"""`Agent.chat` must accept a per-call `seed=` kwarg."""
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "seed" in sig.parameters, (
|
||||
"Agent.chat() must expose seed= to let callers request determinism"
|
||||
)
|
||||
|
||||
def test_seed_reaches_llm_build_params(self, monkeypatch):
|
||||
"""A `seed=42` passed to chat() must appear in the LLM kwargs."""
|
||||
captured = {}
|
||||
|
||||
# Use dict-form llm= so the custom-LLM path (with llm_instance) is taken.
|
||||
agent = Agent(name="C1", instructions="say hi", llm={"model": "gpt-4o-mini"})
|
||||
assert getattr(agent, "llm_instance", None) is not None, \
|
||||
"dict llm= must produce a materialised llm_instance"
|
||||
|
||||
# Short-circuit the LLM call so nothing hits the network
|
||||
monkeypatch.setattr(
|
||||
agent.llm_instance,
|
||||
"get_response",
|
||||
lambda **kw: (captured.update(kw) or "ok"),
|
||||
)
|
||||
|
||||
agent.chat("hello", seed=42)
|
||||
assert captured.get("seed") == 42, (
|
||||
f"seed=42 did not reach LLM call; kwargs were {list(captured)}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C2 — cancel_token wired into chat()
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2CancelToken:
|
||||
def test_chat_signature_accepts_cancel_token(self):
|
||||
import inspect
|
||||
sig = inspect.signature(Agent.chat)
|
||||
assert "cancel_token" in sig.parameters, (
|
||||
"Agent.chat() must expose cancel_token= for cooperative cancellation"
|
||||
)
|
||||
|
||||
def test_preset_cancelled_token_aborts_chat_before_llm(self, monkeypatch):
|
||||
"""If the cancel token is already set when chat() is called,
|
||||
the LLM is NEVER invoked and the call returns cleanly (None)."""
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
|
||||
agent = Agent(name="C2", instructions="irrelevant", llm={"model": "gpt-4o-mini"})
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def must_not_be_called(**kwargs):
|
||||
called["n"] += 1
|
||||
return "should not be reached"
|
||||
|
||||
monkeypatch.setattr(agent.llm_instance, "get_response", must_not_be_called)
|
||||
|
||||
tok = InterruptController()
|
||||
tok.request("user-cancel")
|
||||
|
||||
try:
|
||||
agent.chat("anything", cancel_token=tok)
|
||||
except InterruptedError:
|
||||
pass # acceptable outcome
|
||||
assert called["n"] == 0, (
|
||||
"LLM was invoked despite pre-set cancel_token — cancellation not wired"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C4 — ToolValidatorProtocol wired into tool execution
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _RejectingValidator:
|
||||
"""Minimal ToolValidatorProtocol impl that always rejects."""
|
||||
|
||||
def validate_args(self, tool_name, args, context=None):
|
||||
return ValidationResult(
|
||||
valid=False,
|
||||
errors=[f"arg-validator rejected {tool_name}"],
|
||||
remediation="Fix your tool-call schema.",
|
||||
)
|
||||
|
||||
def validate_result(self, tool_name, result, context=None):
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
|
||||
class TestC4ToolValidator:
|
||||
def test_validator_implements_protocol(self):
|
||||
"""Sanity: our fixture validator satisfies the protocol."""
|
||||
v = _RejectingValidator()
|
||||
assert isinstance(v, ToolValidatorProtocol)
|
||||
|
||||
def test_validator_rejects_before_tool_runs(self):
|
||||
"""When agent._tool_validator rejects, the tool function is NEVER called
|
||||
and the returned string contains the validator's error."""
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_tool(x: int) -> int:
|
||||
calls["n"] += 1
|
||||
return x * 2
|
||||
|
||||
agent = Agent(name="C4", instructions="…", tools=[fake_tool], llm="gpt-4o-mini")
|
||||
agent._tool_validator = _RejectingValidator()
|
||||
|
||||
result = agent.execute_tool("fake_tool", {"x": 5})
|
||||
|
||||
assert calls["n"] == 0, "tool function was executed despite validator rejection"
|
||||
assert "rejected" in str(result).lower(), (
|
||||
f"validator error not surfaced: {result!r}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# C7 — enable_pii_redaction() registers a BEFORE_LLM middleware
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7PIIRedaction:
|
||||
def test_enable_pii_redaction_is_importable(self):
|
||||
from praisonaiagents.trace.redact import enable_pii_redaction
|
||||
assert callable(enable_pii_redaction)
|
||||
|
||||
def test_enable_pii_redaction_is_idempotent(self):
|
||||
"""Calling enable_pii_redaction() twice must not double-register."""
|
||||
from praisonaiagents.trace.redact import (
|
||||
enable_pii_redaction,
|
||||
disable_pii_redaction,
|
||||
)
|
||||
disable_pii_redaction() # ensure clean baseline
|
||||
enable_pii_redaction()
|
||||
first_state = _pii_state_snapshot()
|
||||
enable_pii_redaction()
|
||||
second_state = _pii_state_snapshot()
|
||||
assert first_state == second_state, "enable_pii_redaction is not idempotent"
|
||||
disable_pii_redaction()
|
||||
|
||||
def test_pii_scrubber_scrubs_api_keys(self):
|
||||
"""The scrubber function must redact API-key-shaped strings."""
|
||||
from praisonaiagents.trace.redact import scrub_pii_text
|
||||
|
||||
raw = "my api_key=sk-ABCDEF12345 and password=hunter2"
|
||||
scrubbed = scrub_pii_text(raw)
|
||||
assert "sk-ABCDEF12345" not in scrubbed
|
||||
assert "hunter2" not in scrubbed
|
||||
assert "[REDACTED]" in scrubbed
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pii_state_snapshot():
|
||||
"""Snapshot of hook registry state used by C7 idempotency test."""
|
||||
from praisonaiagents.hooks import get_default_registry
|
||||
reg = get_default_registry()
|
||||
# Count hooks registered for BEFORE_LLM — we just want stable identity
|
||||
hooks = getattr(reg, "_hooks", None) or getattr(reg, "hooks", None) or {}
|
||||
if isinstance(hooks, dict):
|
||||
bl = hooks.get("before_llm") or hooks.get("BEFORE_LLM") or []
|
||||
return len(bl)
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Regression tests for GHSA-gmjg-hv98-qggq:
|
||||
Undeclared __main__ callables must NOT be executed via execute_tool.
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
|
||||
|
||||
class _HookRunner:
|
||||
def execute_sync(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
def is_blocked(self, results):
|
||||
return False
|
||||
|
||||
|
||||
class _DummyAgent(ToolExecutionMixin):
|
||||
def __init__(self, tools=None):
|
||||
self.name = "test_agent"
|
||||
self.tools = tools or []
|
||||
self.chat_history = []
|
||||
self._hook_runner = _HookRunner()
|
||||
self.context_manager = None
|
||||
self._doom_loop_tracker = None
|
||||
self._perm_deny = frozenset()
|
||||
self._perm_allow = None
|
||||
self._approval_backend = None
|
||||
|
||||
|
||||
def _make_approved_registry():
|
||||
reg = MagicMock()
|
||||
reg.approve_sync.return_value = MagicMock(
|
||||
approved=True, reason="mock", modified_args=None
|
||||
)
|
||||
reg.mark_approved = MagicMock()
|
||||
return reg
|
||||
|
||||
|
||||
def _sneaky_undeclared(msg="pwned"):
|
||||
"""Callable present only in __main__, never declared as a tool."""
|
||||
return {"ran": msg}
|
||||
|
||||
|
||||
def test_undeclared_main_callable_not_executed():
|
||||
"""An undeclared callable in __main__ must NOT be invoked."""
|
||||
# Inject the function into __main__ to simulate the exploit scenario.
|
||||
original = getattr(sys.modules["__main__"], "_sneaky_undeclared", None)
|
||||
sys.modules["__main__"]._sneaky_undeclared = _sneaky_undeclared
|
||||
try:
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("_sneaky_undeclared", {"msg": "hello"})
|
||||
# Should NOT have run the function — result must indicate not-found, not success.
|
||||
assert result != {"ran": "hello"}, (
|
||||
"Undeclared __main__ callable was executed — vulnerability still present"
|
||||
)
|
||||
finally:
|
||||
if original is None:
|
||||
delattr(sys.modules["__main__"], "_sneaky_undeclared")
|
||||
else:
|
||||
sys.modules["__main__"]._sneaky_undeclared = original
|
||||
|
||||
|
||||
def test_declared_tool_still_executes():
|
||||
"""A properly declared tool must still be callable after the fix."""
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[my_tool])
|
||||
result = agent.execute_tool("my_tool", {"x": 3})
|
||||
assert result == 6, f"Declared tool did not execute correctly: {result}"
|
||||
|
||||
|
||||
def test_globals_callable_not_executed():
|
||||
"""A callable only in tool_execution module globals must NOT be invoked."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
# 'logging' is a module-level name in tool_execution; it must not be invocable.
|
||||
result = agent.execute_tool("logging", {})
|
||||
assert not isinstance(result, dict) or result.get("ran") is None, (
|
||||
"globals() fallback executed an unintended name"
|
||||
)
|
||||
|
||||
|
||||
def test_undeclared_returns_error_or_none():
|
||||
"""execute_tool with an unknown name should return None or an error dict, not raise."""
|
||||
reg = _make_approved_registry()
|
||||
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
|
||||
agent = _DummyAgent(tools=[])
|
||||
result = agent.execute_tool("completely_nonexistent_xyz", {})
|
||||
# Result should be None or an error dict — not a successful execution.
|
||||
assert result is None or isinstance(result, dict), (
|
||||
f"Unexpected return type: {type(result)}"
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
assert result.get("ran") is None
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for Claude Code subscription auth."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, mock_open
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.auth.subscription.claude_code import (
|
||||
ClaudeCodeAuth,
|
||||
_read_keychain_credentials,
|
||||
_read_file_credentials,
|
||||
_is_expiring,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_keychain_path_returns_none_off_darwin():
|
||||
"""Test keychain reading returns None on non-macOS platforms."""
|
||||
with patch.object(sys, 'platform', 'linux'):
|
||||
assert _read_keychain_credentials() is None
|
||||
|
||||
|
||||
def test_file_path_reads_credentials(monkeypatch):
|
||||
"""Test file credential reading."""
|
||||
# Create a temporary file with Claude Code credentials
|
||||
credentials_data = {
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat-test",
|
||||
"refreshToken": "rt-test",
|
||||
"expiresAt": 9999999999000,
|
||||
}
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
||||
json.dump(credentials_data, tmp)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with patch('praisonaiagents.auth.subscription.claude_code.Path') as mock_path:
|
||||
mock_home_path = mock_path.home.return_value
|
||||
mock_claude_path = mock_home_path / ".claude" / ".credentials.json"
|
||||
mock_claude_path.exists.return_value = True
|
||||
mock_claude_path.read_text.return_value = json.dumps(credentials_data)
|
||||
|
||||
creds = _read_file_credentials()
|
||||
assert creds["accessToken"] == "sk-ant-oat-test"
|
||||
assert creds["refreshToken"] == "rt-test"
|
||||
assert creds["source"] == "claude-code-file"
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_is_expiring():
|
||||
"""Test token expiry detection."""
|
||||
import time
|
||||
current_ms = int(time.time() * 1000)
|
||||
|
||||
# Token expires in 30 seconds (should be considered expiring with default 60s skew)
|
||||
soon_expired = current_ms + 30_000
|
||||
assert _is_expiring(soon_expired) is True
|
||||
|
||||
# Token expires in 2 minutes (should not be expiring)
|
||||
not_expired = current_ms + 120_000
|
||||
assert _is_expiring(not_expired) is False
|
||||
|
||||
# No expiry time
|
||||
assert _is_expiring(0) is False
|
||||
assert _is_expiring(None) is False
|
||||
|
||||
|
||||
def test_resolve_credentials_uses_env_first(monkeypatch):
|
||||
"""Test that environment variables take precedence."""
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat-from-env")
|
||||
|
||||
auth = ClaudeCodeAuth()
|
||||
creds = auth.resolve_credentials()
|
||||
|
||||
assert creds.api_key == "sk-ant-oat-from-env"
|
||||
assert creds.base_url == "https://api.anthropic.com"
|
||||
assert creds.headers["x-app"] == "cli"
|
||||
assert creds.headers["user-agent"].startswith("claude-cli/")
|
||||
assert creds.source == "env:ANTHROPIC_TOKEN"
|
||||
|
||||
|
||||
def test_resolve_credentials_no_auth_raises_error():
|
||||
"""Test that missing credentials raises AuthError."""
|
||||
auth = ClaudeCodeAuth()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_keychain_credentials', return_value=None):
|
||||
with patch('praisonaiagents.auth.subscription.claude_code._read_file_credentials', return_value=None):
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
auth.resolve_credentials()
|
||||
|
||||
assert "No Claude Code credentials found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_headers_for_includes_required_oauth_headers():
|
||||
"""Test that OAuth-specific headers are included."""
|
||||
auth = ClaudeCodeAuth()
|
||||
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
|
||||
|
||||
assert headers["anthropic-beta"] == "oauth-2025-04-20,interleaved-thinking-2025-05-14"
|
||||
assert headers["user-agent"] == "claude-cli/2.1.0 (external, cli)"
|
||||
assert headers["x-app"] == "cli"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for subscription auth protocols."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import (
|
||||
SubscriptionCredentials,
|
||||
SubscriptionAuthProtocol,
|
||||
AuthError
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_dataclass_defaults():
|
||||
"""Test SubscriptionCredentials dataclass defaults."""
|
||||
c = SubscriptionCredentials(api_key="test-key")
|
||||
assert c.api_key == "test-key"
|
||||
assert c.base_url == ""
|
||||
assert c.headers == {}
|
||||
assert c.auth_scheme == "bearer"
|
||||
assert c.expires_at_ms is None
|
||||
assert c.source == ""
|
||||
|
||||
|
||||
def test_protocol_runtime_check():
|
||||
"""Test that classes implementing the protocol are recognized."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test")
|
||||
def refresh(self):
|
||||
return SubscriptionCredentials(api_key="refreshed")
|
||||
def headers_for(self, base_url, model):
|
||||
return {"x-test": "header"}
|
||||
|
||||
assert isinstance(MockAuth(), SubscriptionAuthProtocol)
|
||||
|
||||
|
||||
def test_auth_error():
|
||||
"""Test AuthError exception."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
raise AuthError("test error")
|
||||
assert str(exc_info.value) == "test error"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for subscription auth registry."""
|
||||
import pytest
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
|
||||
from praisonaiagents.auth.subscription import registry as _reg
|
||||
from praisonaiagents.auth.subscription.registry import (
|
||||
register_subscription_provider,
|
||||
list_subscription_providers,
|
||||
resolve_subscription_credentials,
|
||||
_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry():
|
||||
"""Clean the registry before and after each test, resetting the builtin flag.
|
||||
|
||||
Direct access to _REGISTRY and _BUILTIN_REGISTERED is intentional here:
|
||||
we are testing the registry implementation itself and need full state control
|
||||
for test isolation.
|
||||
"""
|
||||
original_registry = _REGISTRY.copy()
|
||||
original_builtin_flag = _reg._BUILTIN_REGISTERED
|
||||
_REGISTRY.clear()
|
||||
_reg._BUILTIN_REGISTERED = False
|
||||
yield
|
||||
_REGISTRY.clear()
|
||||
_REGISTRY.update(original_registry)
|
||||
_reg._BUILTIN_REGISTERED = original_builtin_flag
|
||||
|
||||
|
||||
def test_register_and_resolve():
|
||||
"""Test registering and resolving a provider."""
|
||||
class MockAuth:
|
||||
def resolve_credentials(self):
|
||||
return SubscriptionCredentials(api_key="test-key", source="mock")
|
||||
def refresh(self):
|
||||
return self.resolve_credentials()
|
||||
def headers_for(self, *_):
|
||||
return {}
|
||||
|
||||
register_subscription_provider("test-mock", lambda: MockAuth())
|
||||
assert "test-mock" in list_subscription_providers()
|
||||
|
||||
creds = resolve_subscription_credentials("test-mock")
|
||||
assert creds.api_key == "test-key"
|
||||
assert creds.source == "mock"
|
||||
|
||||
|
||||
def test_resolve_unknown_provider():
|
||||
"""Test resolving an unknown provider raises AuthError."""
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_subscription_credentials("unknown-provider")
|
||||
|
||||
assert "Unknown subscription provider 'unknown-provider'" in str(exc_info.value)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tests for IdentityResolverProtocol and InMemoryIdentityResolver.
|
||||
|
||||
W1 — Cross-platform identity linking. An IdentityResolver maps
|
||||
``(platform, platform_user_id)`` → ``unified_user_id`` so that the same
|
||||
human can be recognised across Telegram, Discord, Slack, etc.
|
||||
|
||||
Linking is OPT-IN. By default the resolver returns the platform-prefixed
|
||||
ID unchanged (no surprises, no privacy leak).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.identity import (
|
||||
IdentityLink,
|
||||
IdentityResolverProtocol,
|
||||
InMemoryIdentityResolver,
|
||||
)
|
||||
|
||||
|
||||
class TestIdentityLink:
|
||||
def test_link_holds_platform_user_unified(self):
|
||||
link = IdentityLink(
|
||||
platform="telegram",
|
||||
platform_user_id="12345",
|
||||
unified_user_id="alice",
|
||||
)
|
||||
assert link.platform == "telegram"
|
||||
assert link.platform_user_id == "12345"
|
||||
assert link.unified_user_id == "alice"
|
||||
|
||||
def test_link_is_immutable(self):
|
||||
link = IdentityLink("telegram", "12345", "alice")
|
||||
with pytest.raises((AttributeError, Exception)):
|
||||
link.unified_user_id = "bob" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
def test_in_memory_satisfies_protocol(self):
|
||||
resolver = InMemoryIdentityResolver()
|
||||
assert isinstance(resolver, IdentityResolverProtocol)
|
||||
|
||||
def test_protocol_has_required_methods(self):
|
||||
proto_methods = {"resolve", "link", "unlink", "links_for"}
|
||||
assert proto_methods.issubset(set(dir(IdentityResolverProtocol)))
|
||||
|
||||
|
||||
class TestInMemoryResolverDefaults:
|
||||
def test_unlinked_returns_platform_prefixed_id(self):
|
||||
"""When no link exists, return ``{platform}:{user_id}`` unchanged."""
|
||||
r = InMemoryIdentityResolver()
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_unlinked_does_not_create_link(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.resolve("telegram", "12345")
|
||||
assert r.links_for("telegram:12345") == []
|
||||
|
||||
|
||||
class TestLinking:
|
||||
def test_link_two_platforms_resolves_to_same_unified(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
assert r.resolve("telegram", "12345") == "alice"
|
||||
assert r.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_reverts_to_default(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.unlink("telegram", "12345")
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_links_for_returns_all_platforms(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("discord", "snowflake-1", "alice")
|
||||
links = r.links_for("alice")
|
||||
assert len(links) == 2
|
||||
platforms = {link.platform for link in links}
|
||||
assert platforms == {"telegram", "discord"}
|
||||
|
||||
def test_link_overwrites_previous(self):
|
||||
r = InMemoryIdentityResolver()
|
||||
r.link("telegram", "12345", "alice")
|
||||
r.link("telegram", "12345", "bob")
|
||||
assert r.resolve("telegram", "12345") == "bob"
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_concurrent_links_dont_corrupt_state(self):
|
||||
import threading
|
||||
|
||||
r = InMemoryIdentityResolver()
|
||||
|
||||
def worker(i):
|
||||
r.link("telegram", f"u{i}", f"unified-{i}")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(50)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
for i in range(50):
|
||||
assert r.resolve("telegram", f"u{i}") == f"unified-{i}"
|
||||
|
||||
|
||||
class TestFileIdentityResolver:
|
||||
def test_persists_across_instances(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.link("discord", "snowflake-1", "alice")
|
||||
|
||||
# New instance, same file
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "alice"
|
||||
assert r2.resolve("discord", "snowflake-1") == "alice"
|
||||
|
||||
def test_unlink_persists(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r1 = FileIdentityResolver(path=path)
|
||||
r1.link("telegram", "12345", "alice")
|
||||
r1.unlink("telegram", "12345")
|
||||
|
||||
r2 = FileIdentityResolver(path=path)
|
||||
assert r2.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_corrupt_file_does_not_crash(self, tmp_path):
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
r = FileIdentityResolver(path=path)
|
||||
# Falls back to empty state
|
||||
assert r.resolve("telegram", "12345") == "telegram:12345"
|
||||
|
||||
def test_satisfies_protocol(self, tmp_path):
|
||||
from praisonaiagents.session.identity import (
|
||||
FileIdentityResolver,
|
||||
IdentityResolverProtocol,
|
||||
)
|
||||
r = FileIdentityResolver(path=tmp_path / "identity.json")
|
||||
assert isinstance(r, IdentityResolverProtocol)
|
||||
|
||||
def test_file_permissions_restricted(self, tmp_path):
|
||||
import sys
|
||||
if sys.platform.startswith("win"):
|
||||
pytest.skip("chmod semantics differ on Windows")
|
||||
|
||||
from praisonaiagents.session.identity import FileIdentityResolver
|
||||
|
||||
path = tmp_path / "identity.json"
|
||||
r = FileIdentityResolver(path=path)
|
||||
r.link("telegram", "12345", "alice")
|
||||
# 0o600 = owner-only read/write
|
||||
mode = path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for task-local SessionContextVars.
|
||||
|
||||
W1 — Concurrent message handlers must NOT clobber each other's
|
||||
session metadata. Use ``contextvars.ContextVar`` so each asyncio task
|
||||
has its own copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.session.context import (
|
||||
SessionContext,
|
||||
clear_session_context,
|
||||
get_session_context,
|
||||
set_session_context,
|
||||
)
|
||||
|
||||
|
||||
class TestSetGet:
|
||||
def test_set_and_get_roundtrips(self):
|
||||
token = set_session_context(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
user_id="alice",
|
||||
)
|
||||
try:
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == "telegram"
|
||||
assert ctx.chat_id == "100"
|
||||
assert ctx.user_id == "alice"
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
def test_default_context_is_empty(self):
|
||||
ctx = get_session_context()
|
||||
assert ctx.platform == ""
|
||||
assert ctx.chat_id == ""
|
||||
assert ctx.user_id == ""
|
||||
|
||||
def test_clear_restores_previous(self):
|
||||
token = set_session_context(platform="telegram", user_id="alice")
|
||||
clear_session_context(token)
|
||||
assert get_session_context().platform == ""
|
||||
|
||||
|
||||
class TestAsyncTaskIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_tasks_dont_clobber(self):
|
||||
seen = []
|
||||
|
||||
async def handler(name, delay):
|
||||
token = set_session_context(platform=name, user_id=name)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
seen.append(get_session_context().platform)
|
||||
finally:
|
||||
clear_session_context(token)
|
||||
|
||||
await asyncio.gather(
|
||||
handler("telegram", 0.02),
|
||||
handler("discord", 0.01),
|
||||
handler("slack", 0.03),
|
||||
)
|
||||
assert sorted(seen) == ["discord", "slack", "telegram"]
|
||||
|
||||
|
||||
class TestSessionContextDataclass:
|
||||
def test_context_has_all_fields(self):
|
||||
ctx = SessionContext(
|
||||
platform="telegram",
|
||||
chat_id="100",
|
||||
chat_name="DM",
|
||||
thread_id="t1",
|
||||
user_id="alice",
|
||||
user_name="Alice",
|
||||
unified_user_id="alice-global",
|
||||
)
|
||||
assert ctx.unified_user_id == "alice-global"
|
||||
|
||||
def test_context_dict_roundtrip(self):
|
||||
ctx = SessionContext(platform="telegram", user_id="alice")
|
||||
d = ctx.to_dict()
|
||||
ctx2 = SessionContext.from_dict(d)
|
||||
assert ctx2.platform == "telegram"
|
||||
assert ctx2.user_id == "alice"
|
||||
@@ -76,6 +76,10 @@ def mock_transport():
|
||||
def client(mock_transport):
|
||||
c = PushClient("ws://test:8765/ws", auto_reconnect=False)
|
||||
c._transport = mock_transport
|
||||
# ``PushClient._send`` checks ``self._transport.is_connected`` (not the
|
||||
# internal ``_connected`` flag), so the mock transport must report itself
|
||||
# as connected for send-paths to succeed.
|
||||
mock_transport.connected = True
|
||||
c._connected = True
|
||||
return c
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2951,7 +2951,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.32"
|
||||
version = "1.6.37"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -48,8 +48,8 @@ async def create_issue(
|
||||
)
|
||||
act_svc = ActivityService(session)
|
||||
await act_svc.log(
|
||||
workspace_id, "issue.created",
|
||||
actor_type="user" if user.is_user else "agent",
|
||||
workspace_id, "issue.created", "issue", issue.id,
|
||||
actor_type="member" if user.is_user else "agent",
|
||||
actor_id=user.id, issue_id=issue.id,
|
||||
details={"title": issue.title, "identifier": issue.identifier},
|
||||
)
|
||||
@@ -116,8 +116,8 @@ async def update_issue(
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
act_svc = ActivityService(session)
|
||||
await act_svc.log(
|
||||
workspace_id, "issue.updated",
|
||||
actor_type="user" if user.is_user else "agent",
|
||||
workspace_id, "issue.updated", "issue", issue.id,
|
||||
actor_type="member" if user.is_user else "agent",
|
||||
actor_id=user.id, issue_id=issue.id,
|
||||
details={"fields": body.model_dump(exclude_none=True)},
|
||||
)
|
||||
|
||||
@@ -161,8 +161,8 @@ class IssueResponse(BaseModel):
|
||||
priority: str
|
||||
assignee_type: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
creator_type: str
|
||||
creator_id: str
|
||||
creator_type: Optional[str] = None
|
||||
creator_id: Optional[str] = None
|
||||
number: Optional[int] = None
|
||||
identifier: Optional[str] = None
|
||||
parent_issue_id: Optional[str] = None
|
||||
@@ -183,8 +183,8 @@ class CommentCreate(BaseModel):
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
issue_id: str
|
||||
author_type: str
|
||||
author_id: str
|
||||
author_type: Optional[str] = None
|
||||
author_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
content: str
|
||||
type: str
|
||||
@@ -223,6 +223,7 @@ class AgentResponse(BaseModel):
|
||||
instructions: Optional[str] = None
|
||||
status: str
|
||||
max_concurrent_tasks: int
|
||||
owner_type: Optional[str] = None
|
||||
owner_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -277,6 +278,8 @@ class DependencyResponse(BaseModel):
|
||||
class ActivityLogResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
issue_id: Optional[str] = None
|
||||
actor_type: Optional[str] = None
|
||||
actor_id: Optional[str] = None
|
||||
|
||||
@@ -49,9 +49,11 @@ class Workspace(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
|
||||
issue_prefix: Mapped[str] = mapped_column(String, default="ISSUE")
|
||||
issue_prefix: Mapped[str] = mapped_column(String, default="ISS")
|
||||
issue_counter: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -88,8 +90,12 @@ class Project(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
workspace_id: Mapped[str] = mapped_column(String, ForeignKey("workspaces.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="planned")
|
||||
lead_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
lead_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
@@ -110,9 +116,15 @@ class Issue(Base):
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="backlog")
|
||||
priority: Mapped[str] = mapped_column(String, nullable=False, default="medium")
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||
created_by_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||
issue_number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
assignee_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
creator_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
creator_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
parent_issue_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("issues.id"), nullable=True)
|
||||
acceptance_criteria: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True, default=list)
|
||||
number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
identifier: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -122,6 +134,11 @@ class Issue(Base):
|
||||
comments: Mapped[List["Comment"]] = relationship("Comment", back_populates="issue")
|
||||
labels: Mapped[List["IssueLabelLink"]] = relationship("IssueLabelLink", back_populates="issue")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workspace_id", "number", name="uq_issue_workspace_number"),
|
||||
UniqueConstraint("workspace_id", "identifier", name="uq_issue_workspace_identifier"),
|
||||
)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""Comment model for issue discussions."""
|
||||
@@ -129,7 +146,10 @@ class Comment(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
issue_id: Mapped[str] = mapped_column(String, ForeignKey("issues.id"), nullable=False)
|
||||
author_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||
parent_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("comments.id"), nullable=True)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False, default="comment")
|
||||
author_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
author_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
@@ -146,7 +166,13 @@ class Agent(Base):
|
||||
workspace_id: Mapped[str] = mapped_column(String, ForeignKey("workspaces.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="idle")
|
||||
status: Mapped[str] = mapped_column(String, nullable=False, default="offline")
|
||||
owner_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
owner_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
runtime_mode: Mapped[str] = mapped_column(String, nullable=False, default="local")
|
||||
runtime_config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
|
||||
instructions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
max_concurrent_tasks: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
@@ -195,12 +221,12 @@ class IssueDependency(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
issue_id: Mapped[str] = mapped_column(String, ForeignKey("issues.id"), nullable=False)
|
||||
depends_on_id: Mapped[str] = mapped_column(String, ForeignKey("issues.id"), nullable=False)
|
||||
dependency_type: Mapped[str] = mapped_column(String, nullable=False) # blocks, blocked_by, related
|
||||
depends_on_issue_id: Mapped[str] = mapped_column(String, ForeignKey("issues.id"), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False) # blocks, blocked_by, related
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("issue_id", "depends_on_id", "dependency_type", name="uq_issue_dependency"),
|
||||
UniqueConstraint("issue_id", "depends_on_issue_id", "type", name="uq_issue_dependency"),
|
||||
)
|
||||
|
||||
|
||||
@@ -213,6 +239,8 @@ class ActivityLog(Base):
|
||||
entity_type: Mapped[str] = mapped_column(String, nullable=False) # issue, project, workspace, etc.
|
||||
entity_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String, nullable=False) # created, updated, deleted, etc.
|
||||
actor_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||
actor_type: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
actor_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
issue_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("issues.id"), nullable=True)
|
||||
details: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
@@ -20,6 +20,8 @@ class ActivityService:
|
||||
self,
|
||||
workspace_id: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
actor_type: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
issue_id: Optional[str] = None,
|
||||
@@ -28,6 +30,8 @@ class ActivityService:
|
||||
"""Record an activity."""
|
||||
entry = ActivityLog(
|
||||
workspace_id=workspace_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
|
||||
@@ -26,6 +26,7 @@ class ProjectService:
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
icon: Optional[str] = None,
|
||||
status: str = "planned",
|
||||
lead_type: Optional[str] = None,
|
||||
lead_id: Optional[str] = None,
|
||||
) -> Project:
|
||||
@@ -35,6 +36,7 @@ class ProjectService:
|
||||
title=title,
|
||||
description=description,
|
||||
icon=icon,
|
||||
status=status,
|
||||
lead_type=lead_type,
|
||||
lead_id=lead_id,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-28",
|
||||
"lastUpdated": "2026-05-08",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
@@ -1104,7 +1104,6 @@
|
||||
"WebSearchProvider"
|
||||
],
|
||||
"config_loader": [
|
||||
"AutoRagConfig",
|
||||
"SessionConfig"
|
||||
],
|
||||
"context": [
|
||||
@@ -1739,6 +1738,9 @@
|
||||
"sandbox": [],
|
||||
"session": [],
|
||||
"skills": [],
|
||||
"specialized": [
|
||||
"AutoRagConfig"
|
||||
],
|
||||
"specialized_agents": [],
|
||||
"streaming": [],
|
||||
"task": [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-04-28",
|
||||
"lastUpdated": "2026-05-08",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-04-28
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-08
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
+52
-10
@@ -1,27 +1,66 @@
|
||||
"""
|
||||
Auto-generated API server for PraisonAI agents.
|
||||
|
||||
Security defaults
|
||||
-----------------
|
||||
|
||||
Authentication is enabled by default and the server binds to localhost.
|
||||
To run unauthenticated (e.g. inside a trusted private network behind an
|
||||
authenticating proxy) set ``PRAISONAI_API_AUTH=disabled`` explicitly.
|
||||
|
||||
Configuration via environment variables:
|
||||
|
||||
* ``PRAISONAI_API_AUTH`` - ``enabled`` (default) or ``disabled``.
|
||||
* ``PRAISONAI_API_TOKEN`` - bearer token required when auth is enabled.
|
||||
If unset, a random token is generated at
|
||||
startup and printed to stderr.
|
||||
* ``PRAISONAI_API_HOST`` - bind host (default ``127.0.0.1``).
|
||||
* ``PRAISONAI_API_PORT`` - bind port (default ``8080``).
|
||||
"""
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from praisonai import PraisonAI
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# CORS configuration
|
||||
CORS(app)
|
||||
|
||||
# Authentication
|
||||
AUTH_ENABLED = False
|
||||
AUTH_TOKEN = None
|
||||
# Authentication is ON by default. Operators can opt out explicitly with
|
||||
# ``PRAISONAI_API_AUTH=disabled`` if they front the server with another
|
||||
# authenticator. When enabled, a bearer token is required: either set
|
||||
# ``PRAISONAI_API_TOKEN`` or accept the random one generated at startup.
|
||||
AUTH_ENABLED = os.environ.get("PRAISONAI_API_AUTH", "enabled").strip().lower() != "disabled"
|
||||
AUTH_TOKEN = os.environ.get("PRAISONAI_API_TOKEN") or None
|
||||
|
||||
if AUTH_ENABLED and not AUTH_TOKEN:
|
||||
AUTH_TOKEN = secrets.token_urlsafe(32)
|
||||
# Print to stderr so it shows up in logs but never in HTTP responses.
|
||||
print(
|
||||
f"[praisonai-api] generated API token (set PRAISONAI_API_TOKEN to override): {AUTH_TOKEN}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def check_auth():
|
||||
"""Check authentication if enabled."""
|
||||
"""Check authentication if enabled.
|
||||
|
||||
Uses a constant-time comparison to avoid timing oracles that could
|
||||
let an attacker recover the bearer token byte-by-byte.
|
||||
"""
|
||||
if not AUTH_ENABLED:
|
||||
return True
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
return token == AUTH_TOKEN
|
||||
|
||||
if not AUTH_TOKEN:
|
||||
# Fail closed if auth is enabled but no token was configured.
|
||||
return False
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '', 1)
|
||||
return secrets.compare_digest(token, AUTH_TOKEN)
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
@@ -64,8 +103,11 @@ def list_agents():
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Bind to localhost by default. Operators that need to expose the
|
||||
# server externally must opt in via ``PRAISONAI_API_HOST=0.0.0.0`` and
|
||||
# should always pair that with authentication or a fronting proxy.
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=8080,
|
||||
debug=False
|
||||
host=os.environ.get("PRAISONAI_API_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("PRAISONAI_API_PORT", "8080")),
|
||||
debug=False,
|
||||
)
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.32.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.32.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# Suppress crewai.cli.config logger BEFORE any imports to prevent INFO log
|
||||
import logging
|
||||
import threading
|
||||
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
|
||||
|
||||
# Version is lightweight, import directly
|
||||
from .version import __version__
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Safe module loader with PRAISONAI_ALLOW_LOCAL_TOOLS opt-in.
|
||||
|
||||
This module provides a safe way to load user-supplied .py files with the same
|
||||
security posture as tool_resolver.py. All exec_module() calls should route
|
||||
through this helper to ensure consistent opt-in behavior.
|
||||
"""
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalToolsDisabled(RuntimeError):
|
||||
"""Raised when local tools loading is disabled but required."""
|
||||
pass
|
||||
|
||||
|
||||
def load_user_module(module_path: str | Path, *, name: str) -> ModuleType | None:
|
||||
"""Load a user-supplied .py file with the same opt-in tool_resolver enforces.
|
||||
|
||||
Args:
|
||||
module_path: Path to the .py file to load
|
||||
name: Module name to use for the spec
|
||||
|
||||
Returns:
|
||||
Loaded module or None if loading is disabled or the file is missing.
|
||||
|
||||
Raises:
|
||||
LocalToolsDisabled: If caller wants strict behavior when disabled.
|
||||
"""
|
||||
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
|
||||
logger.warning(
|
||||
"Refusing to exec %s: set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.",
|
||||
module_path,
|
||||
)
|
||||
return None
|
||||
|
||||
path = Path(module_path).resolve()
|
||||
if not path.is_file():
|
||||
return None
|
||||
|
||||
# Optional: enforce that the path is under CWD or an explicit allowlist
|
||||
# to prevent ../-style traversal from API/network inputs.
|
||||
cwd = Path.cwd().resolve()
|
||||
try:
|
||||
path.relative_to(cwd)
|
||||
except ValueError:
|
||||
logger.warning("Refusing to exec %s: outside working directory.", path)
|
||||
return None
|
||||
|
||||
spec = importlib.util.spec_from_file_location(name, str(path))
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def load_user_module_strict(module_path: str | Path, *, name: str) -> ModuleType:
|
||||
"""Load a user-supplied .py file, raising LocalToolsDisabled if disabled.
|
||||
|
||||
Args:
|
||||
module_path: Path to the .py file to load
|
||||
name: Module name to use for the spec
|
||||
|
||||
Returns:
|
||||
Loaded module
|
||||
|
||||
Raises:
|
||||
LocalToolsDisabled: If PRAISONAI_ALLOW_LOCAL_TOOLS is not set to 'true'
|
||||
FileNotFoundError: If the file doesn't exist
|
||||
"""
|
||||
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
|
||||
raise LocalToolsDisabled(
|
||||
f"Refusing to exec {module_path}: set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable."
|
||||
)
|
||||
|
||||
path = Path(module_path).resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Module file not found: {path}")
|
||||
|
||||
# Security: enforce that the path is under CWD
|
||||
cwd = Path.cwd().resolve()
|
||||
try:
|
||||
path.relative_to(cwd)
|
||||
except ValueError:
|
||||
raise LocalToolsDisabled(f"Refusing to exec {path}: outside working directory.")
|
||||
|
||||
spec = importlib.util.spec_from_file_location(name, str(path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not create spec for {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import logging
|
||||
import re
|
||||
import keyword
|
||||
import difflib
|
||||
|
||||
# Import new architecture components
|
||||
from .framework_adapters.base import FrameworkAdapter
|
||||
@@ -232,10 +233,8 @@ class AgentsGenerator:
|
||||
self.tool_registry = ToolRegistry()
|
||||
self.tool_registry.register_builtin_autogen_adapters()
|
||||
|
||||
# Get framework adapter and validate availability
|
||||
# Get framework adapter (availability already validated at CLI entry)
|
||||
self.framework_adapter = self._get_framework_adapter(framework)
|
||||
if not self.framework_adapter.is_available():
|
||||
raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.")
|
||||
|
||||
def _get_framework_adapter(self, framework: str) -> FrameworkAdapter:
|
||||
"""
|
||||
@@ -321,6 +320,47 @@ class AgentsGenerator:
|
||||
agent_config[field] = value
|
||||
self.logger.debug(f"CLI override for agent {agent_name}: {field} = {value}")
|
||||
|
||||
def _validate_agents_config(self, config):
|
||||
"""
|
||||
Validate agent configuration for typos in field names and provide suggestions.
|
||||
|
||||
Args:
|
||||
config (dict): The parsed YAML configuration
|
||||
"""
|
||||
known_fields = {
|
||||
'role', 'goal', 'instructions', 'backstory', 'tools', 'tasks', 'llm',
|
||||
'function_calling_llm', 'allow_delegation', 'max_iter', 'max_rpm',
|
||||
'max_execution_time', 'verbose', 'cache', 'system_template',
|
||||
'prompt_template', 'response_template', 'tool_timeout', 'planning_tools',
|
||||
'planning', 'autonomy', 'guardrails', 'streaming', 'stream',
|
||||
'approval', 'skills', 'cli_backend', 'reflection'
|
||||
}
|
||||
|
||||
for section_name in ('agents', 'roles'):
|
||||
section = config.get(section_name, {})
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
|
||||
entity_name = 'agent' if section_name == 'agents' else 'role'
|
||||
for name, section_config in section.items():
|
||||
if not isinstance(section_config, dict):
|
||||
continue
|
||||
|
||||
for field_name in section_config:
|
||||
if field_name in known_fields:
|
||||
continue
|
||||
|
||||
close_matches = difflib.get_close_matches(
|
||||
field_name,
|
||||
known_fields,
|
||||
n=1,
|
||||
cutoff=0.6
|
||||
)
|
||||
suggestion = f" Did you mean '{close_matches[0]}'?" if close_matches else ""
|
||||
self.logger.warning(
|
||||
f"Unknown field '{field_name}' in {entity_name} '{name}'.{suggestion}"
|
||||
)
|
||||
|
||||
def is_function_or_decorated(self, obj):
|
||||
"""
|
||||
Checks if the given object is a function or has a __call__ method.
|
||||
@@ -351,6 +391,24 @@ class AgentsGenerator:
|
||||
spec.loader.exec_module(module)
|
||||
return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)}
|
||||
|
||||
def _extract_tool_classes(self, module):
|
||||
"""
|
||||
Extract tool classes from a loaded module that inherit from BaseTool
|
||||
or are part of langchain_community.tools package.
|
||||
"""
|
||||
result = {}
|
||||
for name, obj in inspect.getmembers(module,
|
||||
lambda x: inspect.isclass(x) and (
|
||||
x.__module__.startswith('langchain_community.tools') or
|
||||
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
|
||||
) and x is not BaseTool):
|
||||
try:
|
||||
result[name] = obj()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error instantiating tool class {name}: {e}")
|
||||
continue
|
||||
return result
|
||||
|
||||
def load_tools_from_module_class(self, module_path):
|
||||
"""
|
||||
Loads tools from a specified module path containing classes that inherit from BaseTool
|
||||
@@ -403,16 +461,13 @@ class AgentsGenerator:
|
||||
"""
|
||||
tools_list = []
|
||||
try:
|
||||
# Try to import tools.py from current directory
|
||||
spec = importlib.util.spec_from_file_location("tools", "tools.py")
|
||||
self.logger.debug(f"Spec: {spec}")
|
||||
if spec is None:
|
||||
self.logger.debug("tools.py not found in current directory")
|
||||
# Try to import tools.py from current directory using safe loading
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module("tools.py", name="tools")
|
||||
if module is None:
|
||||
self.logger.debug("tools.py not found or local tools loading disabled")
|
||||
return tools_list
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Register functions in the tool registry instead of globals()
|
||||
registered_tools = self.tool_registry.register_from_module(module)
|
||||
tools_list = [self.tool_registry.get_function(name) for name in registered_tools]
|
||||
@@ -491,6 +546,10 @@ class AgentsGenerator:
|
||||
|
||||
# Get workflow input: 'input' is canonical, 'topic' is alias for backward compatibility
|
||||
topic = config.get('input', config.get('topic', ''))
|
||||
|
||||
# Validate agents configuration for typos in field names
|
||||
self._validate_agents_config(config)
|
||||
|
||||
tools_dict = {}
|
||||
|
||||
# Use ToolResolver to get available tools (consistent tool resolution)
|
||||
@@ -532,11 +591,21 @@ class AgentsGenerator:
|
||||
tools_dir_path = Path(root_directory) / 'tools'
|
||||
|
||||
if os.path.isfile(tools_py_path):
|
||||
tools_dict.update(self.load_tools_from_module_class(tools_py_path))
|
||||
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
|
||||
from ._safe_loader import load_user_module
|
||||
module = load_user_module(tools_py_path, name="tools_module")
|
||||
if module is not None:
|
||||
tools_dict.update(self._extract_tool_classes(module))
|
||||
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
|
||||
elif tools_dir_path.is_dir():
|
||||
tools_dict.update(self.load_tools_from_module_class(tools_dir_path))
|
||||
self.logger.debug("tools folder exists in the root directory")
|
||||
from ._safe_loader import load_user_module
|
||||
for py_file in tools_dir_path.glob("*.py"):
|
||||
if py_file.name.startswith("__"):
|
||||
continue
|
||||
module = load_user_module(py_file, name=f"tools_{py_file.stem}")
|
||||
if module is not None:
|
||||
tools_dict.update(self._extract_tool_classes(module))
|
||||
if tools_dict:
|
||||
self.logger.debug("tools folder exists in the root directory")
|
||||
|
||||
framework = self.framework or config.get('framework', 'crewai')
|
||||
|
||||
@@ -572,12 +641,20 @@ class AgentsGenerator:
|
||||
self.framework = framework
|
||||
self.framework_adapter = self._get_framework_adapter(framework)
|
||||
|
||||
# Final availability check
|
||||
if not self.framework_adapter.is_available():
|
||||
raise ImportError(f"Framework '{framework}' is not available. Please install the required dependencies.")
|
||||
|
||||
# Validate framework availability for non-CLI callers
|
||||
from .framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(framework)
|
||||
|
||||
self.logger.info(f"Using framework: {framework}")
|
||||
return self.framework_adapter.run(config, self.config_list, topic)
|
||||
return self.framework_adapter.run(
|
||||
config,
|
||||
self.config_list,
|
||||
topic,
|
||||
tools_dict=tools_dict,
|
||||
agent_callback=getattr(self, 'agent_callback', None),
|
||||
task_callback=getattr(self, 'task_callback', None),
|
||||
cli_config=getattr(self, 'cli_config', None),
|
||||
)
|
||||
|
||||
def _run_yaml_workflow(self, config):
|
||||
"""
|
||||
@@ -859,10 +936,13 @@ class AgentsGenerator:
|
||||
api_type = _resolve("api_type", default="openai").lower()
|
||||
model_name = _resolve("model", default="gpt-4o-mini")
|
||||
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
|
||||
# Use resolver for consistent env-var precedence as fallback
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
base_url = (model_config.get("base_url")
|
||||
or yaml_llm.get("base_url")
|
||||
or os.environ.get("OPENAI_BASE_URL")
|
||||
or os.environ.get("OPENAI_API_BASE"))
|
||||
or ep.base_url)
|
||||
|
||||
# Build LLMConfig — Bedrock needs no api_key
|
||||
if api_type == "bedrock":
|
||||
|
||||
@@ -69,11 +69,25 @@ tools_path = os.path.join(os.getcwd(), 'tools.py')
|
||||
logger.debug(f"Tools path: {tools_path}")
|
||||
|
||||
def import_tools_from_file(file_path):
|
||||
spec = importlib.util.spec_from_file_location("custom_tools", file_path)
|
||||
custom_tools_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custom_tools_module)
|
||||
logger.debug(f"Imported tools from {file_path}")
|
||||
return custom_tools_module
|
||||
"""Import tools from file with PRAISONAI_ALLOW_LOCAL_TOOLS opt-in.
|
||||
|
||||
This function is reachable from network input via API requests.
|
||||
Additional security: only allow files under the current working directory.
|
||||
"""
|
||||
from .._safe_loader import load_user_module_strict, LocalToolsDisabled
|
||||
try:
|
||||
custom_tools_module = load_user_module_strict(file_path, name="custom_tools")
|
||||
logger.debug(f"Imported tools from {file_path}")
|
||||
return custom_tools_module
|
||||
except LocalToolsDisabled as e:
|
||||
logger.warning(f"Tools loading disabled: {e}")
|
||||
raise ValueError("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Tools file not found: {e}")
|
||||
raise ValueError(f"Tools file not found: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error("Failed to import tools from %s", file_path, exc_info=True)
|
||||
raise ValueError(f"Failed to import tools from {file_path}") from e
|
||||
|
||||
try:
|
||||
# Security: Require explicit opt-in for local tools loading
|
||||
|
||||
@@ -116,7 +116,6 @@ class AsyncAgentScheduler:
|
||||
|
||||
self.is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
self._executor = AsyncPraisonAgentExecutor(agent)
|
||||
self._execution_count = 0
|
||||
self._success_count = 0
|
||||
@@ -124,6 +123,7 @@ class AsyncAgentScheduler:
|
||||
|
||||
# Sync lock for async primitives creation and bound loop tracking
|
||||
self._primitives_lock = threading.Lock()
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._cancel_event: Optional[asyncio.Event] = None
|
||||
self._stats_lock: Optional[asyncio.Lock] = None
|
||||
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
@@ -138,6 +138,7 @@ class AsyncAgentScheduler:
|
||||
|
||||
with self._primitives_lock:
|
||||
if self._bound_loop is not loop:
|
||||
self._stop_event = asyncio.Event()
|
||||
self._cancel_event = asyncio.Event()
|
||||
self._stats_lock = asyncio.Lock()
|
||||
self._bound_loop = loop
|
||||
@@ -167,6 +168,7 @@ class AsyncAgentScheduler:
|
||||
try:
|
||||
interval = ScheduleParser.parse(schedule_expr)
|
||||
self.is_running = True
|
||||
self._ensure_async_primitives() # NEW — bind to the loop start() runs on
|
||||
self._stop_event.clear()
|
||||
|
||||
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
|
||||
@@ -208,19 +210,22 @@ class AsyncAgentScheduler:
|
||||
logger.info("Async scheduler is not running")
|
||||
return True
|
||||
|
||||
# Ensure we're on the same loop that was bound during start()
|
||||
try:
|
||||
current_loop = asyncio.get_running_loop()
|
||||
if self._bound_loop is not None and current_loop is not self._bound_loop:
|
||||
raise RuntimeError(
|
||||
"stop() must be called from the same event loop as start(). "
|
||||
f"Expected: {self._bound_loop}, got: {current_loop}"
|
||||
)
|
||||
except RuntimeError:
|
||||
# No running loop - this is fine if scheduler was never started
|
||||
pass
|
||||
|
||||
logger.info("Stopping async agent scheduler...")
|
||||
|
||||
# _stop_event is guaranteed non-None after a successful start()
|
||||
if self._stop_event is None:
|
||||
logger.warning("stop() called before start(); nothing to stop.")
|
||||
self.is_running = False
|
||||
return True
|
||||
|
||||
# Ensure we're on the same loop that was bound during start()
|
||||
current_loop = asyncio.get_running_loop()
|
||||
if self._bound_loop is not None and current_loop is not self._bound_loop:
|
||||
raise RuntimeError(
|
||||
"stop() must be called from the same event loop as start(). "
|
||||
f"Expected: {self._bound_loop}, got: {current_loop}"
|
||||
)
|
||||
|
||||
self._stop_event.set()
|
||||
|
||||
try:
|
||||
@@ -250,7 +255,11 @@ class AsyncAgentScheduler:
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get execution statistics.
|
||||
Get current execution statistics (synchronous, best-effort).
|
||||
|
||||
Warning: This method provides a best-effort view of stats without
|
||||
guaranteeing atomicity. For consistent snapshots in async context,
|
||||
use get_stats_async() instead.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
@@ -263,9 +272,44 @@ class AsyncAgentScheduler:
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0
|
||||
}
|
||||
|
||||
async def get_stats_async(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics with atomic snapshot (async).
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
if self._stats_lock is None:
|
||||
# Not yet started: stats are all zero, no lock needed
|
||||
execs, success, failed = 0, 0, 0
|
||||
else:
|
||||
# Take atomic snapshot of all counters
|
||||
async with self._stats_lock:
|
||||
execs = self._execution_count
|
||||
success = self._success_count
|
||||
failed = self._failure_count
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": execs,
|
||||
"successful_executions": success,
|
||||
"failed_executions": failed,
|
||||
"success_rate": (success / execs * 100) if execs > 0 else 0
|
||||
}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Alias for get_stats() for clarity.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
return self.get_stats()
|
||||
|
||||
async def _run_schedule(self, interval: int, max_retries: int):
|
||||
"""Internal method to run scheduled agent executions."""
|
||||
try:
|
||||
self._ensure_async_primitives()
|
||||
while not self._stop_event.is_set():
|
||||
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting async scheduled agent execution")
|
||||
|
||||
@@ -283,7 +327,13 @@ class AsyncAgentScheduler:
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic."""
|
||||
self._execution_count += 1
|
||||
# Ensure async primitives are available
|
||||
if self._stats_lock is None:
|
||||
self._ensure_async_primitives()
|
||||
|
||||
# Atomically increment execution count
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
@@ -294,7 +344,9 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
self._success_count += 1
|
||||
# Atomically increment success count
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
safe_call(self.on_success, result)
|
||||
return
|
||||
|
||||
@@ -307,7 +359,9 @@ class AsyncAgentScheduler:
|
||||
logger.info(f"Waiting {wait_time}s before async retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
self._failure_count += 1
|
||||
# Atomically increment failure count
|
||||
async with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Async agent execution failed after {max_retries} attempts")
|
||||
safe_call(
|
||||
self.on_failure,
|
||||
|
||||
@@ -467,19 +467,15 @@ class BaseAutoGenerator:
|
||||
Args:
|
||||
config_list: Optional LLM configuration list
|
||||
"""
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': os.environ.get("OPENAI_API_KEY")
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key
|
||||
}
|
||||
]
|
||||
|
||||
@@ -649,42 +645,28 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
# Initialize base class first (handles config_list and client)
|
||||
super().__init__(config_list=config_list)
|
||||
|
||||
# Validate framework availability and show framework-specific messages
|
||||
if framework == "crewai" and not _check_crewai_available():
|
||||
raise ImportError("""
|
||||
CrewAI is not installed. Please install with:
|
||||
pip install "praisonai[crewai]"
|
||||
""")
|
||||
elif framework == "autogen" and not (_check_autogen_available() or _check_autogen_v4_available()):
|
||||
raise ImportError("""
|
||||
AutoGen is not installed. Please install with:
|
||||
pip install "praisonai[autogen]" for v0.2
|
||||
pip install "praisonai[autogen-v4]" for v0.4
|
||||
""")
|
||||
elif framework == "praisonai" and not _check_praisonai_available():
|
||||
raise ImportError("""
|
||||
Praisonai is not installed. Please install with:
|
||||
pip install praisonaiagents
|
||||
""")
|
||||
elif framework == "ag2" and not _check_ag2_available():
|
||||
raise ImportError("""
|
||||
AG2 is not installed. Please install with:
|
||||
pip install "praisonai[ag2]"
|
||||
""")
|
||||
# Validate framework availability using adapter registry
|
||||
from .framework_adapters.registry import FrameworkAdapterRegistry
|
||||
|
||||
registry = FrameworkAdapterRegistry.get_instance()
|
||||
try:
|
||||
adapter = registry.create(framework)
|
||||
except ValueError as e:
|
||||
raise ImportError(
|
||||
f"Unknown framework '{framework}'. Available frameworks: "
|
||||
f"{', '.join(registry.list_registered())}"
|
||||
) from e
|
||||
|
||||
# Only show tools message if using a framework and tools are needed
|
||||
if (framework in ["crewai", "autogen"]) and not _check_praisonai_tools_available():
|
||||
if framework == "autogen":
|
||||
logger.warning("""
|
||||
Tools are not available for autogen. To use tools, install:
|
||||
pip install "praisonai[autogen]" for v0.2
|
||||
pip install "praisonai[autogen-v4]" for v0.4
|
||||
""")
|
||||
else:
|
||||
logger.warning(f"""
|
||||
Tools are not available for {framework}. To use tools, install:
|
||||
pip install "praisonai[{framework}]"
|
||||
""")
|
||||
# Use safe fallbacks for new adapter attributes
|
||||
install_hint = getattr(adapter, "install_hint", f"pip install {framework}")
|
||||
requires_tools_extra = bool(getattr(adapter, "requires_tools_extra", False))
|
||||
|
||||
if not adapter.is_available():
|
||||
raise ImportError(f"{adapter.name} is not installed. Please install with:\n {install_hint}")
|
||||
|
||||
# Check tools availability if required by this framework
|
||||
if requires_tools_extra and not _check_praisonai_tools_available():
|
||||
logger.warning(f"Tools are not available for {framework}. To use tools, install:\n {install_hint}")
|
||||
|
||||
self.topic = topic
|
||||
self.agent_file = agent_file
|
||||
|
||||
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
||||
from .discord import DiscordBot
|
||||
from .slack import SlackBot
|
||||
from .whatsapp import WhatsAppBot
|
||||
from .linear import LinearBot
|
||||
from .email import EmailBot
|
||||
from .agentmail import AgentMailBot
|
||||
from .bot import Bot
|
||||
@@ -36,6 +37,9 @@ def __getattr__(name: str):
|
||||
if name == "WhatsAppBot":
|
||||
from .whatsapp import WhatsAppBot
|
||||
return WhatsAppBot
|
||||
if name == "LinearBot":
|
||||
from .linear import LinearBot
|
||||
return LinearBot
|
||||
if name == "EmailBot":
|
||||
from .email import EmailBot
|
||||
return EmailBot
|
||||
@@ -63,11 +67,20 @@ def __getattr__(name: str):
|
||||
if name == "HTTPApproval":
|
||||
from ._http_approval import HTTPApproval
|
||||
return HTTPApproval
|
||||
# W1 — cross-platform mirror + identity
|
||||
if name == "mirror_to_session":
|
||||
from ._mirror import mirror_to_session
|
||||
return mirror_to_session
|
||||
if name == "BotSessionManager":
|
||||
from ._session import BotSessionManager
|
||||
return BotSessionManager
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "EmailBot", "AgentMailBot",
|
||||
"TelegramBot", "DiscordBot", "SlackBot", "WhatsAppBot", "LinearBot", "EmailBot", "AgentMailBot",
|
||||
"Bot", "BotOS",
|
||||
"SlackApproval", "TelegramApproval", "DiscordApproval",
|
||||
"WebhookApproval", "HTTPApproval",
|
||||
# W1
|
||||
"mirror_to_session", "BotSessionManager",
|
||||
]
|
||||
@@ -94,12 +94,15 @@ async def classify_with_llm(
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
client = OpenAI(
|
||||
api_key=_os.environ.get("OPENAI_API_KEY", ""),
|
||||
base_url=_os.environ.get("OPENAI_BASE_URL"),
|
||||
api_key=ep.api_key or "",
|
||||
base_url=ep.base_url,
|
||||
)
|
||||
response = client.chat.completions.create(
|
||||
model=_os.environ.get("APPROVAL_LLM_MODEL", "gpt-4o-mini"),
|
||||
model=_os.environ.get("APPROVAL_LLM_MODEL", ep.model),
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
|
||||
@@ -233,19 +233,14 @@ async function decide(d) {{
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=self._timeout + 10)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
"""Synchronous wrapper — delegates to the shared async bridge."""
|
||||
from .._async_bridge import run_sync
|
||||
# run_sync raises RuntimeError if called from a running loop, so callers
|
||||
# in async contexts get a clear error instead of a silent thread cold-start.
|
||||
return run_sync(
|
||||
self.request_approval(request),
|
||||
timeout=self._timeout + 10,
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Stop the HTTP server gracefully."""
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Outbound delivery mirror for bot sessions.
|
||||
|
||||
W1 — When a bot pushes a message via ``Bot.send_message()`` (notifications,
|
||||
scheduled deliveries, cross-platform replies), this module appends an
|
||||
``{"role": "assistant", "mirror": True}`` entry to the user's session
|
||||
so the next inbound turn carries that context.
|
||||
|
||||
Standalone helper — designed to be safe to call from any context (sync
|
||||
handler, async coroutine, cron job, scheduled delivery). Errors are
|
||||
swallowed and logged: a mirror failure must never break the outbound
|
||||
delivery itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# @claude: No module-level lock here by design.
|
||||
# A process-wide _mirror_lock = threading.RLock() would serialise mirror_to_session()
|
||||
# calls across ALL BotSessionManager instances — slow disk I/O for one bot would
|
||||
# block mirrors for every other bot in the process.
|
||||
# Lock ownership belongs to the caller (passed via the optional ``lock`` param),
|
||||
# or a fresh per-call RLock is created as fallback — scoped to one call only.
|
||||
|
||||
|
||||
def mirror_to_session(
|
||||
session_mgr: Any,
|
||||
user_id: str,
|
||||
message_text: str,
|
||||
source_label: str = "delivery",
|
||||
metadata: Optional[dict] = None,
|
||||
lock: Optional[threading.RLock] = None,
|
||||
) -> bool:
|
||||
"""Append a mirror entry to ``user_id``'s session history.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session_mgr
|
||||
A ``BotSessionManager``-shaped object that exposes
|
||||
``_storage_key(user_id)``, ``_load_history(user_id)``, and
|
||||
``_save_history(user_id, history)``. Any object with these three
|
||||
methods is acceptable (duck-typed, no Protocol required).
|
||||
user_id
|
||||
Raw platform user id. Will be passed through the manager's
|
||||
identity resolver if one is configured.
|
||||
message_text
|
||||
The outbound message text to mirror.
|
||||
source_label
|
||||
Free-form tag identifying the source of the delivery
|
||||
(e.g. ``"cron"``, ``"web"``, ``"cross_platform"``).
|
||||
metadata
|
||||
Optional extra metadata to merge into the mirror entry.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` on success, ``False`` if anything went wrong. Errors
|
||||
are logged at WARN level and never raised.
|
||||
"""
|
||||
if not message_text:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Verify that session_mgr is a compatible BotSessionManager-shaped
|
||||
# object by probing its _storage_key method (duck-typing check).
|
||||
session_mgr._storage_key(user_id)
|
||||
except Exception as e:
|
||||
logger.warning("mirror: storage_key failed: %s", e)
|
||||
return False
|
||||
|
||||
# Check if the session manager has a thread-safe mirror method
|
||||
# This allows the session manager to handle synchronization properly
|
||||
# with its internal asyncio locks.
|
||||
if hasattr(session_mgr, '_add_mirror_entry_sync'):
|
||||
try:
|
||||
entry: dict = {
|
||||
"role": "assistant",
|
||||
"content": message_text,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"mirror": True,
|
||||
"mirror_source": source_label,
|
||||
}
|
||||
if metadata:
|
||||
# Filter out reserved fields to preserve entry invariants
|
||||
_reserved = {"role", "content", "timestamp", "mirror", "mirror_source"}
|
||||
entry.update({k: v for k, v in metadata.items() if k not in _reserved})
|
||||
|
||||
return session_mgr._add_mirror_entry_sync(user_id, entry)
|
||||
except Exception as e:
|
||||
logger.warning("mirror: _add_mirror_entry_sync failed: %s", e)
|
||||
return False
|
||||
|
||||
# Fallback: session manager has no _add_mirror_entry_sync.
|
||||
# Use the caller-supplied lock, or a fresh per-call RLock — never a global.
|
||||
logger.warning(
|
||||
"mirror_to_session using fallback synchronization - "
|
||||
"session manager should implement _add_mirror_entry_sync for better safety"
|
||||
)
|
||||
_call_lock = lock if lock is not None else threading.RLock()
|
||||
with _call_lock:
|
||||
try:
|
||||
# Load current history
|
||||
history = list(session_mgr._load_history(user_id))
|
||||
|
||||
# Create mirror entry
|
||||
entry: dict = {
|
||||
"role": "assistant",
|
||||
"content": message_text,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"mirror": True,
|
||||
"mirror_source": source_label,
|
||||
}
|
||||
if metadata:
|
||||
# Filter out reserved fields to preserve entry invariants
|
||||
_reserved = {"role", "content", "timestamp", "mirror", "mirror_source"}
|
||||
entry.update({k: v for k, v in metadata.items() if k not in _reserved})
|
||||
history.append(entry)
|
||||
|
||||
# Save updated history atomically within the lock
|
||||
session_mgr._save_history(user_id, history)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("mirror: save_history failed: %s", e)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["mirror_to_session"]
|
||||
@@ -15,6 +15,7 @@ _BUILTIN_PLATFORMS: Dict[str, tuple] = {
|
||||
"discord": ("praisonai.bots.discord", "DiscordBot"),
|
||||
"slack": ("praisonai.bots.slack", "SlackBot"),
|
||||
"whatsapp": ("praisonai.bots.whatsapp", "WhatsAppBot"),
|
||||
"linear": ("praisonai.bots.linear", "LinearBot"),
|
||||
"email": ("praisonai.bots.email", "EmailBot"),
|
||||
"agentmail": ("praisonai.bots.agentmail", "AgentMailBot"),
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class BotSessionManager:
|
||||
max_history: int = 100,
|
||||
store: Optional[Any] = None,
|
||||
platform: str = "",
|
||||
identity_resolver: Optional[Any] = None,
|
||||
) -> None:
|
||||
self._histories: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
@@ -70,17 +71,46 @@ class BotSessionManager:
|
||||
self._store = store
|
||||
self._platform = platform
|
||||
self._last_active: Dict[str, float] = {}
|
||||
# W1: optional cross-platform identity resolver. When set, the
|
||||
# session key is the resolver-returned unified user id, so the
|
||||
# same human pinging from multiple platforms shares one history.
|
||||
self._identity_resolver = identity_resolver
|
||||
|
||||
def _storage_key(self, user_id: str) -> str:
|
||||
"""Resolve a raw platform user id to the in-memory/store key.
|
||||
|
||||
With an identity resolver this is the unified user id; without
|
||||
one, behaviour is unchanged (raw ``user_id``).
|
||||
"""
|
||||
if self._identity_resolver is not None and self._platform:
|
||||
try:
|
||||
return self._identity_resolver.resolve(self._platform, user_id)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("identity resolver failed: %s", e)
|
||||
return user_id
|
||||
|
||||
def _persist_key(self, storage_key: str) -> str:
|
||||
"""Derive the persistent-store key from an in-memory storage key.
|
||||
|
||||
With a resolver, ``storage_key`` is already the unified user id
|
||||
and is used directly. Without a resolver, the legacy
|
||||
``bot_{platform}_{user_id}`` prefix is preserved for back-compat.
|
||||
"""
|
||||
if self._identity_resolver is not None and self._platform:
|
||||
return storage_key
|
||||
prefix = f"bot_{self._platform}" if self._platform else "bot"
|
||||
return f"{prefix}_{storage_key}"
|
||||
|
||||
def _session_key(self, user_id: str) -> str:
|
||||
"""Generate a deterministic session key for persistent storage."""
|
||||
prefix = f"bot_{self._platform}" if self._platform else "bot"
|
||||
return f"{prefix}_{user_id}"
|
||||
"""Persistent-store key for a raw platform user id (back-compat API)."""
|
||||
return self._persist_key(self._storage_key(user_id))
|
||||
|
||||
def _get_lock(self, user_id: str) -> asyncio.Lock:
|
||||
"""Get or create an asyncio.Lock for *user_id*."""
|
||||
if user_id not in self._locks:
|
||||
self._locks[user_id] = asyncio.Lock()
|
||||
return self._locks[user_id]
|
||||
"""Get or create an asyncio.Lock for *user_id* (storage-keyed)."""
|
||||
key = self._storage_key(user_id)
|
||||
if key not in self._locks:
|
||||
self._locks[key] = asyncio.Lock()
|
||||
return self._locks[key]
|
||||
|
||||
def _get_agent_lock(self, agent: "Agent") -> asyncio.Lock:
|
||||
"""Get or create a lock for the *agent* instance (by id)."""
|
||||
@@ -99,7 +129,7 @@ class BotSessionManager:
|
||||
return list(history)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load session from store: %s", e)
|
||||
return list(self._histories.get(user_id, []))
|
||||
return list(self._histories.get(self._storage_key(user_id), []))
|
||||
|
||||
def _save_history(
|
||||
self, user_id: str, history: List[Dict[str, Any]]
|
||||
@@ -108,8 +138,8 @@ class BotSessionManager:
|
||||
if self._max_history > 0 and len(history) > self._max_history:
|
||||
history = history[-self._max_history:]
|
||||
|
||||
# Always update in-memory cache
|
||||
self._histories[user_id] = history
|
||||
# Always update in-memory cache (keyed by storage key)
|
||||
self._histories[self._storage_key(user_id)] = history
|
||||
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
@@ -131,6 +161,9 @@ class BotSessionManager:
|
||||
agent: "Agent",
|
||||
user_id: str,
|
||||
prompt: str,
|
||||
chat_id: str = "",
|
||||
thread_id: str = "",
|
||||
user_name: str = "",
|
||||
) -> str:
|
||||
"""Run ``agent.chat(prompt)`` with *user_id*-scoped history.
|
||||
|
||||
@@ -140,35 +173,76 @@ class BotSessionManager:
|
||||
Uses both a per-user lock (serialise same user) and a per-agent
|
||||
lock (prevent concurrent history swaps on a shared Agent).
|
||||
"""
|
||||
self._last_active[user_id] = time.monotonic()
|
||||
self._last_active[self._storage_key(user_id)] = time.monotonic()
|
||||
user_lock = self._get_lock(user_id)
|
||||
agent_lock = self._get_agent_lock(agent)
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_running_loop()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
|
||||
# W1: set task-local session context so any tool the agent
|
||||
# invokes can read platform / chat / user metadata without
|
||||
# relying on os.environ globals.
|
||||
ctx_token = None
|
||||
_clear_ctx = None
|
||||
try:
|
||||
from praisonaiagents.session.context import (
|
||||
set_session_context as _set_ctx,
|
||||
clear_session_context as _clear_ctx,
|
||||
)
|
||||
ctx_token = _set_ctx(
|
||||
platform=self._platform,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
unified_user_id=self._storage_key(user_id),
|
||||
)
|
||||
except Exception: # pragma: no cover — defensive
|
||||
_clear_ctx = None # type: ignore[assignment]
|
||||
|
||||
async with agent_lock:
|
||||
# Swap histories
|
||||
saved_history = agent.chat_history
|
||||
agent.chat_history = user_history
|
||||
try:
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_running_loop()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
response = await loop.run_in_executor(None, agent.chat, prompt)
|
||||
finally:
|
||||
# W1 robustness: hold ``agent_lock`` across the FULL LLM call
|
||||
# (not only the history swap) so concurrent users on a shared
|
||||
# Agent instance never observe each other's chat_history.
|
||||
# Throughput on a shared agent is then bounded by the LLM's
|
||||
# serial latency — this is correct: a single Agent's
|
||||
# ``chat_history`` is mutable and cannot be safely interleaved.
|
||||
async with agent_lock:
|
||||
# Capture updated history and restore agent's original
|
||||
updated_history = agent.chat_history
|
||||
agent.chat_history = saved_history
|
||||
saved_history = agent.chat_history
|
||||
agent.chat_history = user_history
|
||||
try:
|
||||
# Copy current task's contextvars (incl. SessionContext)
|
||||
# into the worker thread so tools the agent invokes can
|
||||
# read platform/user metadata.
|
||||
import contextvars
|
||||
_ctx = contextvars.copy_context()
|
||||
response = await loop.run_in_executor(
|
||||
None, _ctx.run, agent.chat, prompt
|
||||
)
|
||||
# Capture updated history before restoring caller's.
|
||||
updated_history = agent.chat_history
|
||||
finally:
|
||||
agent.chat_history = saved_history
|
||||
|
||||
# Persist (may hit disk via run_in_executor)
|
||||
# Persist outside the agent_lock — it's per-user and the agent
|
||||
# is no longer touched.
|
||||
await loop.run_in_executor(
|
||||
None, self._save_history, user_id, updated_history
|
||||
)
|
||||
|
||||
return response
|
||||
return response
|
||||
finally:
|
||||
# Always clear task-local session context, even if an exception occurred.
|
||||
if ctx_token is not None and _clear_ctx is not None:
|
||||
try:
|
||||
_clear_ctx(ctx_token)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to clear task-local session context for ctx_token=%r: %s", ctx_token, e)
|
||||
|
||||
def reap_stale(self, max_age_seconds: int) -> int:
|
||||
"""Remove sessions older than *max_age_seconds*. Returns count reaped.
|
||||
@@ -183,12 +257,12 @@ class BotSessionManager:
|
||||
uid for uid, ts in self._last_active.items()
|
||||
if (now - ts) > max_age_seconds
|
||||
]
|
||||
for uid in stale:
|
||||
self._histories.pop(uid, None)
|
||||
self._last_active.pop(uid, None)
|
||||
self._locks.pop(uid, None)
|
||||
for storage_key in stale:
|
||||
self._histories.pop(storage_key, None)
|
||||
self._last_active.pop(storage_key, None)
|
||||
self._locks.pop(storage_key, None)
|
||||
if self._store is not None:
|
||||
key = self._session_key(uid)
|
||||
key = self._persist_key(storage_key)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
except Exception as e:
|
||||
@@ -199,26 +273,109 @@ class BotSessionManager:
|
||||
|
||||
def reset(self, user_id: str) -> bool:
|
||||
"""Clear a user's session history. Returns True if it existed."""
|
||||
existed = user_id in self._histories
|
||||
self._histories.pop(user_id, None)
|
||||
self._last_active.pop(user_id, None)
|
||||
storage_key = self._storage_key(user_id)
|
||||
existed = storage_key in self._histories
|
||||
self._histories.pop(storage_key, None)
|
||||
self._last_active.pop(storage_key, None)
|
||||
self._locks.pop(storage_key, None)
|
||||
|
||||
if self._store is not None:
|
||||
key = self._session_key(user_id)
|
||||
persist_key = self._session_key(user_id)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
self._store.clear_session(persist_key)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to clear session in store: %s", e)
|
||||
|
||||
return existed
|
||||
|
||||
def _add_mirror_entry_sync(self, user_id: str, entry: dict) -> bool:
|
||||
"""Thread-safe method to add a mirror entry to user's history.
|
||||
|
||||
This method coordinates with the asyncio locks used by chat()
|
||||
to prevent race conditions. Called by mirror_to_session().
|
||||
|
||||
Args:
|
||||
user_id: Raw platform user id
|
||||
entry: Mirror entry dict to append to history
|
||||
|
||||
Returns:
|
||||
bool: True on success, False on failure
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def _sync_add_entry():
|
||||
# Get the event loop that owns the asyncio locks
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No event loop running - we need to use threading synchronization
|
||||
# This happens when called from sync contexts like cron jobs
|
||||
storage_key = self._storage_key(user_id)
|
||||
self._last_active[storage_key] = time.monotonic()
|
||||
|
||||
# Create a temporary threading lock for this user to prevent concurrent
|
||||
# access to the same user's history from multiple sync threads
|
||||
import threading
|
||||
user_sync_lock = getattr(self, '_user_sync_locks', None)
|
||||
if user_sync_lock is None:
|
||||
self._user_sync_locks = {}
|
||||
user_sync_lock = self._user_sync_locks
|
||||
|
||||
if storage_key not in user_sync_lock:
|
||||
user_sync_lock[storage_key] = threading.Lock()
|
||||
|
||||
with user_sync_lock[storage_key]:
|
||||
history = list(self._load_history(user_id))
|
||||
history.append(entry)
|
||||
self._save_history(user_id, history)
|
||||
return True
|
||||
|
||||
# There's an event loop - we need to coordinate with asyncio locks
|
||||
# This is more complex but necessary for thread safety
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._add_mirror_entry_async(user_id, entry), loop
|
||||
)
|
||||
return future.result(timeout=10.0) # 10 second timeout
|
||||
|
||||
try:
|
||||
return _sync_add_entry()
|
||||
except Exception as e:
|
||||
logger.warning("_add_mirror_entry_sync failed: %s", e)
|
||||
return False
|
||||
|
||||
async def _add_mirror_entry_async(self, user_id: str, entry: dict) -> bool:
|
||||
"""Async helper for _add_mirror_entry_sync."""
|
||||
try:
|
||||
storage_key = self._storage_key(user_id)
|
||||
self._last_active[storage_key] = time.monotonic()
|
||||
|
||||
# Use the same per-user lock that chat() uses
|
||||
user_lock = self._get_lock(user_id)
|
||||
async with user_lock:
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_running_loop()
|
||||
history = await loop.run_in_executor(
|
||||
None, self._load_history, user_id
|
||||
)
|
||||
history = list(history) # Ensure it's mutable
|
||||
history.append(entry)
|
||||
|
||||
# Save updated history
|
||||
await loop.run_in_executor(
|
||||
None, self._save_history, user_id, history
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("_add_mirror_entry_async failed: %s", e)
|
||||
return False
|
||||
|
||||
def reset_all(self) -> int:
|
||||
"""Clear all user sessions. Returns the count cleared."""
|
||||
count = len(self._histories)
|
||||
|
||||
if self._store is not None:
|
||||
for user_id in list(self._histories.keys()):
|
||||
key = self._session_key(user_id)
|
||||
for storage_key in list(self._histories.keys()):
|
||||
key = self._persist_key(storage_key)
|
||||
try:
|
||||
self._store.clear_session(key)
|
||||
except Exception as e:
|
||||
|
||||
@@ -178,19 +178,14 @@ class WebhookApproval:
|
||||
)
|
||||
|
||||
def request_approval_sync(self, request) -> Any:
|
||||
"""Synchronous wrapper — runs async method in a new event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, self.request_approval(request))
|
||||
return future.result(timeout=self._timeout + 10)
|
||||
else:
|
||||
return asyncio.run(self.request_approval(request))
|
||||
"""Synchronous wrapper — delegates to the shared async bridge."""
|
||||
from .._async_bridge import run_sync
|
||||
# run_sync raises RuntimeError if called from a running loop, so callers
|
||||
# in async contexts get a clear error instead of a silent thread cold-start.
|
||||
return run_sync(
|
||||
self.request_approval(request),
|
||||
timeout=self._timeout + 10,
|
||||
)
|
||||
|
||||
# ── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ _TOKEN_ENV_MAP = {
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"whatsapp": "WHATSAPP_ACCESS_TOKEN",
|
||||
"linear": "LINEAR_OAUTH_TOKEN",
|
||||
"email": "EMAIL_APP_PASSWORD",
|
||||
"agentmail": "AGENTMAIL_API_KEY",
|
||||
}
|
||||
@@ -53,6 +54,7 @@ _TOKEN_ENV_MAP = {
|
||||
_EXTRA_ENV_MAP = {
|
||||
"slack": {"app_token": "SLACK_APP_TOKEN"},
|
||||
"whatsapp": {"phone_number_id": "WHATSAPP_PHONE_NUMBER_ID"},
|
||||
"linear": {"signing_secret": "LINEAR_WEBHOOK_SECRET"},
|
||||
"email": {
|
||||
"email_address": "EMAIL_ADDRESS",
|
||||
"imap_server": "EMAIL_IMAP_SERVER",
|
||||
@@ -85,12 +87,18 @@ class Bot:
|
||||
agent: Optional[Any] = None,
|
||||
token: Optional[str] = None,
|
||||
config: Optional[Any] = None,
|
||||
identity_resolver: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._platform = platform.lower().strip()
|
||||
self._agent = agent
|
||||
self._explicit_token = token
|
||||
self._config = config
|
||||
# W1: optional cross-platform identity resolver. Applied to the
|
||||
# adapter's ``_session`` after construction (duck-typed; works
|
||||
# with any adapter that exposes a BotSessionManager-compatible
|
||||
# ``_session`` attribute).
|
||||
self._identity_resolver = identity_resolver
|
||||
self._kwargs = kwargs
|
||||
|
||||
self._adapter: Optional[Any] = None
|
||||
@@ -178,7 +186,24 @@ class Bot:
|
||||
# Merge user kwargs (override defaults)
|
||||
init_kwargs.update(self._kwargs)
|
||||
|
||||
return adapter_cls(**init_kwargs)
|
||||
adapter = adapter_cls(**init_kwargs)
|
||||
|
||||
# W1: post-construction wire-up for the identity resolver.
|
||||
# Adapters create their own BotSessionManager during __init__;
|
||||
# we splice the resolver in here so existing adapters need no
|
||||
# signature change.
|
||||
if self._identity_resolver is not None:
|
||||
session = getattr(adapter, "_session", None)
|
||||
if session is not None and hasattr(session, "_identity_resolver"):
|
||||
session._identity_resolver = self._identity_resolver
|
||||
else:
|
||||
logger.warning(
|
||||
"Bot(%s): adapter has no BotSessionManager-compatible "
|
||||
"_session; identity_resolver ignored.",
|
||||
self._platform,
|
||||
)
|
||||
|
||||
return adapter
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Build the adapter and start the bot."""
|
||||
|
||||
@@ -69,10 +69,14 @@ class BotOS:
|
||||
agent: Optional[Any] = None,
|
||||
platforms: Optional[List[str]] = None,
|
||||
config: Optional[Any] = None,
|
||||
identity_resolver: Optional[Any] = None,
|
||||
):
|
||||
self._bots: Dict[str, Bot] = {}
|
||||
self._is_running = False
|
||||
self._config = config
|
||||
# W1: shared identity resolver applied to every managed bot —
|
||||
# gives cross-platform unified-user sessions out of the box.
|
||||
self._identity_resolver = identity_resolver
|
||||
self._tasks: List[asyncio.Task] = []
|
||||
|
||||
# Register explicit bots
|
||||
@@ -84,7 +88,13 @@ class BotOS:
|
||||
if agent and platforms:
|
||||
for plat in platforms:
|
||||
if plat not in self._bots:
|
||||
self.add_bot(Bot(plat, agent=agent))
|
||||
self.add_bot(
|
||||
Bot(
|
||||
plat,
|
||||
agent=agent,
|
||||
identity_resolver=self._identity_resolver,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +110,14 @@ class BotOS:
|
||||
Args:
|
||||
bot: A Bot instance.
|
||||
"""
|
||||
# W1: propagate the BotOS-level resolver to bots that don't
|
||||
# already have one, so cross-platform unification works whether
|
||||
# the user uses the shortcut API or constructs Bots manually.
|
||||
if (
|
||||
self._identity_resolver is not None
|
||||
and getattr(bot, "_identity_resolver", None) is None
|
||||
):
|
||||
bot._identity_resolver = self._identity_resolver
|
||||
self._bots[bot.platform] = bot
|
||||
|
||||
def list_bots(self) -> List[str]:
|
||||
|
||||
@@ -247,7 +247,11 @@ class DiscordBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
async def _send_agent_response():
|
||||
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
|
||||
response = await self._session.chat(self._agent, user_id, text_to_send)
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text_to_send,
|
||||
chat_id=str(message.channel.id),
|
||||
user_name=str(getattr(message.author, "name", "")),
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
str(message.channel.id), str(response),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
Linear Bot implementation for PraisonAI.
|
||||
|
||||
Supports Linear agent integration via AgentSession webhooks.
|
||||
|
||||
Usage:
|
||||
bot = LinearBot(token="linear-oauth-token", agent=agent,
|
||||
signing_secret="webhook-secret")
|
||||
await bot.start()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
from praisonai.bots._protocol_mixin import ChatCommandMixin, MessageHookMixin
|
||||
from praisonaiagents.bots import (
|
||||
BotConfig,
|
||||
BotMessage,
|
||||
BotUser,
|
||||
BotChannel,
|
||||
MessageType,
|
||||
)
|
||||
|
||||
from ._commands import format_status, format_help
|
||||
from ._session import BotSessionManager
|
||||
from ._rate_limit import RateLimiter
|
||||
from ._ack import AckReactor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Linear GraphQL endpoint
|
||||
LINEAR_API_BASE = "https://api.linear.app/graphql"
|
||||
|
||||
|
||||
class LinearBot(ChatCommandMixin, MessageHookMixin):
|
||||
"""Linear bot runtime for PraisonAI agents.
|
||||
|
||||
Connects an agent to Linear via AgentSession webhooks, handling
|
||||
mentions, assignments, and providing full bot functionality.
|
||||
|
||||
Example:
|
||||
from praisonai.bots import LinearBot
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="assistant")
|
||||
bot = LinearBot(
|
||||
token="YOUR_OAUTH_TOKEN",
|
||||
signing_secret="YOUR_WEBHOOK_SECRET",
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await bot.start()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str = "",
|
||||
agent: Optional["Agent"] = None,
|
||||
config: Optional[BotConfig] = None,
|
||||
signing_secret: str = "",
|
||||
webhook_port: int = 8080,
|
||||
webhook_path: str = "/webhook",
|
||||
**kwargs,
|
||||
):
|
||||
# Store extra kwargs for forward compatibility
|
||||
self._extra_kwargs = kwargs
|
||||
|
||||
# Determine token source for proper authorization format
|
||||
self._oauth_token = token or os.environ.get("LINEAR_OAUTH_TOKEN", "")
|
||||
self._api_key = os.environ.get("LINEAR_API_KEY", "") if not self._oauth_token else ""
|
||||
self._token = self._oauth_token or self._api_key
|
||||
self._is_oauth = bool(self._oauth_token)
|
||||
self._agent = agent
|
||||
self.config = config or BotConfig(token=self._token, mode="webhook")
|
||||
self._signing_secret = signing_secret or os.environ.get("LINEAR_WEBHOOK_SECRET", "")
|
||||
self._webhook_port = webhook_port
|
||||
self._webhook_path = webhook_path
|
||||
|
||||
self._is_running = False
|
||||
self._started_at: Optional[float] = None
|
||||
self._bot_user: Optional[BotUser] = None
|
||||
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
_store = get_default_session_store()
|
||||
except Exception:
|
||||
_store = None
|
||||
|
||||
self._session_mgr = BotSessionManager(
|
||||
store=_store,
|
||||
platform="linear",
|
||||
)
|
||||
self._message_handlers: List[Callable] = []
|
||||
self._runner: Any = None
|
||||
self._site: Any = None
|
||||
self._http_session: Any = None
|
||||
self._background_tasks: set = set()
|
||||
self._rate_limiter = RateLimiter.for_platform("linear")
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
)
|
||||
|
||||
# ChatCommandMixin setup
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
self._command_info: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Register built-in commands
|
||||
self._register_builtins()
|
||||
|
||||
def _register_builtins(self) -> None:
|
||||
"""Register built-in /status, /new, /help commands."""
|
||||
|
||||
async def _status(msg):
|
||||
return format_status(self._agent, "linear", self._started_at, self._is_running)
|
||||
|
||||
async def _new(msg):
|
||||
user_id = msg.sender.user_id if msg.sender else "unknown"
|
||||
self._session_mgr.reset(user_id)
|
||||
return "Session reset. Send a message to start a new conversation."
|
||||
|
||||
async def _help(msg):
|
||||
extra = {
|
||||
name: info.get("description", "")
|
||||
for name, info in self._command_info.items()
|
||||
if name not in ("status", "new", "help")
|
||||
}
|
||||
return format_help(self._agent, "linear", extra or None)
|
||||
|
||||
self.register_command("status", _status, description="Show bot status and info")
|
||||
self.register_command("new", _new, description="Reset conversation session")
|
||||
self.register_command("help", _help, description="Show this help message")
|
||||
|
||||
# ── Properties ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
@property
|
||||
def platform(self) -> str:
|
||||
return "linear"
|
||||
|
||||
@property
|
||||
def bot_user(self) -> Optional[BotUser]:
|
||||
return self._bot_user
|
||||
|
||||
# ── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Linear webhook server."""
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
if not self._token:
|
||||
raise ValueError("LINEAR_OAUTH_TOKEN or LINEAR_API_KEY required")
|
||||
|
||||
if not self._signing_secret:
|
||||
logger.warning("LINEAR_WEBHOOK_SECRET not set - webhook signatures will not be verified")
|
||||
|
||||
logger.info(f"Starting Linear bot on port {self._webhook_port}")
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
raise ImportError("aiohttp required: pip install aiohttp")
|
||||
|
||||
# Create HTTP session for API calls
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
|
||||
# Create webhook server
|
||||
app = web.Application()
|
||||
app.router.add_post(self._webhook_path, self._handle_webhook)
|
||||
app.router.add_get(self._webhook_path, self._handle_webhook_verify)
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
|
||||
self._site = web.TCPSite(self._runner, "0.0.0.0", self._webhook_port)
|
||||
await self._site.start()
|
||||
|
||||
self._is_running = True
|
||||
self._started_at = time.time()
|
||||
|
||||
# Get bot user info
|
||||
await self._fetch_bot_user()
|
||||
|
||||
logger.info(f"Linear bot started on http://0.0.0.0:{self._webhook_port}{self._webhook_path}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Linear webhook server."""
|
||||
if not self._is_running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Linear bot...")
|
||||
|
||||
# Wait for background tasks to complete
|
||||
if self._background_tasks:
|
||||
logger.info(f"Waiting for {len(self._background_tasks)} background tasks...")
|
||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
||||
|
||||
# Stop HTTP server
|
||||
if self._site:
|
||||
await self._site.stop()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
|
||||
# Close HTTP session
|
||||
if self._http_session:
|
||||
await self._http_session.close()
|
||||
|
||||
self._is_running = False
|
||||
self._started_at = None
|
||||
logger.info("Linear bot stopped")
|
||||
|
||||
# ── Webhook Handling ─────────────────────────────────────────────
|
||||
|
||||
async def _handle_webhook_verify(self, request) -> Any:
|
||||
"""Handle webhook verification (GET request)."""
|
||||
from aiohttp import web
|
||||
return web.Response(status=200, text="Linear webhook endpoint")
|
||||
|
||||
async def _handle_webhook(self, request) -> Any:
|
||||
"""Handle incoming Linear webhooks."""
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
# Read raw body for signature verification
|
||||
raw_body = await request.read()
|
||||
|
||||
# Verify signature if secret is configured
|
||||
if self._signing_secret:
|
||||
signature = request.headers.get("Linear-Signature", "")
|
||||
if not self._verify_signature(raw_body, signature):
|
||||
logger.warning("Invalid webhook signature")
|
||||
return web.Response(status=401, text="Invalid signature")
|
||||
|
||||
# Parse JSON body
|
||||
try:
|
||||
body = json.loads(raw_body.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.error(f"Invalid JSON payload: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
# Check timestamp for replay protection (Linear recommendation: 60s window)
|
||||
webhook_timestamp = body.get("webhookTimestamp", 0)
|
||||
if webhook_timestamp:
|
||||
time_diff = abs(time.time() * 1000 - webhook_timestamp)
|
||||
if time_diff > 60_000: # 60 seconds in milliseconds
|
||||
logger.warning(f"Webhook timestamp too old: {time_diff}ms")
|
||||
return web.Response(status=401, text="Timestamp too old")
|
||||
|
||||
# Get event type
|
||||
event_type = request.headers.get("Linear-Event", "")
|
||||
|
||||
# Process webhook in background to avoid blocking
|
||||
task = asyncio.create_task(self._process_webhook(event_type, body))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook handling error: {e}")
|
||||
return web.Response(status=500, text="Internal error")
|
||||
|
||||
def _verify_signature(self, body: bytes, signature: str) -> bool:
|
||||
"""Verify Linear webhook signature using HMAC-SHA256."""
|
||||
if not self._signing_secret or not signature:
|
||||
return False
|
||||
|
||||
expected = hmac.new(
|
||||
self._signing_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
async def _process_webhook(self, event_type: str, body: Dict[str, Any]) -> None:
|
||||
"""Process webhook events."""
|
||||
try:
|
||||
logger.debug(f"Processing {event_type} event")
|
||||
|
||||
if event_type == "AgentSession":
|
||||
await self._handle_agent_session(body)
|
||||
elif event_type == "Comment":
|
||||
await self._handle_comment(body)
|
||||
elif event_type == "Issue":
|
||||
await self._handle_issue(body)
|
||||
else:
|
||||
logger.debug(f"Ignoring {event_type} event")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {event_type} webhook: {e}")
|
||||
|
||||
async def _handle_agent_session(self, body: Dict[str, Any]) -> None:
|
||||
"""Handle AgentSession events (mentions, assignments)."""
|
||||
action = body.get("action")
|
||||
data = body.get("data", {})
|
||||
|
||||
if action == "create":
|
||||
# New agent session created (mention or assignment)
|
||||
session_id = data.get("id")
|
||||
issue_data = data.get("issue", {})
|
||||
|
||||
if not session_id or not issue_data:
|
||||
logger.warning("Missing session or issue data")
|
||||
return
|
||||
|
||||
# Create bot message from issue
|
||||
issue_id = issue_data.get("id", "")
|
||||
issue_title = issue_data.get("title", "")
|
||||
issue_description = issue_data.get("description", "")
|
||||
|
||||
message_text = f"Issue: {issue_title}"
|
||||
if issue_description:
|
||||
message_text += f"\n\n{issue_description}"
|
||||
|
||||
bot_message = BotMessage(
|
||||
message_id=session_id,
|
||||
content=message_text,
|
||||
message_type=MessageType.TEXT,
|
||||
channel=BotChannel(channel_id=issue_id, name=f"Issue {issue_data.get('identifier', '')}"),
|
||||
sender=BotUser(user_id="linear-system", display_name="Linear"),
|
||||
timestamp=time.time(),
|
||||
metadata={"issue": issue_data, "session_id": session_id}
|
||||
)
|
||||
|
||||
# Process with agent
|
||||
await self._handle_agent_message(bot_message)
|
||||
|
||||
async def _handle_comment(self, body: Dict[str, Any]) -> None:
|
||||
"""Handle Comment events."""
|
||||
# For future implementation - comment threads
|
||||
logger.debug("Comment event received")
|
||||
|
||||
async def _handle_issue(self, body: Dict[str, Any]) -> None:
|
||||
"""Handle Issue events."""
|
||||
# For future implementation - issue updates
|
||||
logger.debug("Issue event received")
|
||||
|
||||
async def _handle_agent_message(self, message: BotMessage) -> None:
|
||||
"""Process message with the agent."""
|
||||
if not self._agent:
|
||||
logger.warning("No agent configured")
|
||||
return
|
||||
|
||||
try:
|
||||
user_id = message.sender.user_id if message.sender else "unknown"
|
||||
session_id = message.metadata.get("session_id") if message.metadata else None
|
||||
|
||||
# Use BotSessionManager.chat() which handles history isolation and run_in_executor
|
||||
response = await self._session_mgr.chat(self._agent, user_id, message.content)
|
||||
|
||||
# Send response back to Linear
|
||||
if response and message.metadata:
|
||||
await self._send_comment(
|
||||
issue_id=message.metadata.get("issue", {}).get("id", ""),
|
||||
comment=response,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing agent message: {e}")
|
||||
|
||||
# ── Linear API Methods ──────────────────────────────────────────
|
||||
|
||||
async def _fetch_bot_user(self) -> None:
|
||||
"""Fetch bot user information from Linear API."""
|
||||
try:
|
||||
query = """query {
|
||||
viewer { id name email }
|
||||
}"""
|
||||
|
||||
data = await self._gql_query(query)
|
||||
viewer = data.get("viewer", {})
|
||||
|
||||
if viewer:
|
||||
self._bot_user = BotUser(
|
||||
user_id=viewer.get("id", ""),
|
||||
display_name=viewer.get("name", "Linear Bot"),
|
||||
metadata=viewer
|
||||
)
|
||||
logger.info(f"Connected as: {self._bot_user.display_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch bot user: {e}")
|
||||
|
||||
async def _send_comment(self, issue_id: str, comment: str, session_id: Optional[str] = None) -> None:
|
||||
"""Send a comment to a Linear issue."""
|
||||
try:
|
||||
mutation = """mutation($input: CommentCreateInput!) {
|
||||
commentCreate(input: $input) {
|
||||
success
|
||||
comment { id url }
|
||||
}
|
||||
}"""
|
||||
|
||||
variables = {
|
||||
"input": {
|
||||
"issueId": issue_id,
|
||||
"body": comment
|
||||
}
|
||||
}
|
||||
|
||||
data = await self._gql_query(mutation, variables)
|
||||
|
||||
if data.get("commentCreate", {}).get("success"):
|
||||
logger.info(f"Comment posted to issue {issue_id}")
|
||||
|
||||
# Optionally update agent session status
|
||||
if session_id:
|
||||
await self._update_agent_session(session_id, "response", comment)
|
||||
else:
|
||||
logger.error("Failed to post comment to Linear")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending comment: {e}")
|
||||
|
||||
async def _update_agent_session(self, session_id: str, activity_type: str, content: str) -> None:
|
||||
"""Update agent session with activity."""
|
||||
try:
|
||||
mutation = """mutation($input: AgentActivityCreateInput!) {
|
||||
agentActivityCreate(input: $input) {
|
||||
success
|
||||
}
|
||||
}"""
|
||||
|
||||
variables = {
|
||||
"input": {
|
||||
"agentSessionId": session_id,
|
||||
"type": activity_type,
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
await self._gql_query(mutation, variables)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not update agent session: {e}")
|
||||
|
||||
async def _gql_query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Execute GraphQL query against Linear API."""
|
||||
if not self._http_session:
|
||||
raise RuntimeError("HTTP session not initialized")
|
||||
|
||||
# OAuth tokens require Bearer prefix, API keys are sent raw
|
||||
auth_header = f"Bearer {self._token}" if self._is_oauth else self._token
|
||||
headers = {
|
||||
"Authorization": auth_header,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"variables": variables or {}
|
||||
}
|
||||
|
||||
import aiohttp
|
||||
async with self._http_session.post(
|
||||
LINEAR_API_BASE,
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response:
|
||||
data = await response.json()
|
||||
|
||||
if "errors" in data:
|
||||
raise RuntimeError(f"Linear API error: {data['errors']}")
|
||||
|
||||
return data.get("data", {})
|
||||
|
||||
# ── Agent Integration ───────────────────────────────────────────
|
||||
|
||||
def set_agent(self, agent: "Agent") -> None:
|
||||
"""Set the agent that handles messages."""
|
||||
self._agent = agent
|
||||
|
||||
def get_agent(self) -> Optional["Agent"]:
|
||||
"""Get the current agent."""
|
||||
return self._agent
|
||||
|
||||
# ── Message Sending ─────────────────────────────────────────────
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> BotMessage:
|
||||
"""Send a message (comment) to a Linear issue."""
|
||||
text = content if isinstance(content, str) else str(content)
|
||||
try:
|
||||
await self._send_comment(channel_id, text)
|
||||
return BotMessage(
|
||||
message_id=f"linear-{channel_id}-{int(time.time())}",
|
||||
content=text,
|
||||
message_type=MessageType.TEXT,
|
||||
channel=BotChannel(channel_id=channel_id, name="Linear Issue"),
|
||||
sender=self._bot_user or BotUser(user_id="linear-bot", display_name="Linear Bot"),
|
||||
timestamp=time.time(),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"linear_channel": channel_id}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
# Return a failed message rather than None to maintain protocol
|
||||
return BotMessage(
|
||||
message_id=f"linear-failed-{int(time.time())}",
|
||||
content=text,
|
||||
message_type=MessageType.TEXT,
|
||||
channel=BotChannel(channel_id=channel_id, name="Linear Issue"),
|
||||
sender=self._bot_user or BotUser(user_id="linear-bot", display_name="Linear Bot"),
|
||||
timestamp=time.time(),
|
||||
reply_to=reply_to,
|
||||
thread_id=thread_id,
|
||||
metadata={"error": str(e), "linear_channel": channel_id}
|
||||
)
|
||||
@@ -269,7 +269,11 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"Message received: {text[:100]}...")
|
||||
text = await self._debouncer.debounce(user_id, text)
|
||||
response = await self._session.chat(self._agent, user_id, text)
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(channel_id) if channel_id else "",
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
)
|
||||
logger.info(f"Response sent: {response[:100]}...")
|
||||
|
||||
send_result = self.fire_message_sending(channel_id, str(response))
|
||||
@@ -312,7 +316,11 @@ class SlackBot(ChatCommandMixin, MessageHookMixin):
|
||||
try:
|
||||
user_id = event.get("user", "unknown")
|
||||
logger.info(f"@mention received: {text[:100]}...")
|
||||
response = await self._session.chat(self._agent, user_id, text)
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, text,
|
||||
chat_id=str(event.get("channel", "")),
|
||||
thread_id=event.get("thread_ts", "") or "",
|
||||
)
|
||||
logger.info(f"Response sent: {response[:100]}...")
|
||||
|
||||
# Determine if we should reply in thread
|
||||
|
||||
@@ -250,9 +250,16 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
ack_ctx = await self._ack.ack(react_fn=_tg_react)
|
||||
|
||||
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
|
||||
user_name = (
|
||||
update.message.from_user.username or update.message.from_user.first_name or ""
|
||||
) if update.message.from_user else ""
|
||||
try:
|
||||
message_text = await self._debouncer.debounce(user_id, message_text)
|
||||
response = await self._session.chat(self._agent, user_id, message_text)
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, message_text,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
user_name=user_name,
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
str(update.message.chat_id), str(response),
|
||||
reply_to=str(update.message.message_id),
|
||||
|
||||
@@ -2707,9 +2707,10 @@ def launch_browser(
|
||||
if debug:
|
||||
console.print("[dim] [DEBUG] Page loaded, content script should be active[/dim]")
|
||||
break
|
||||
except:
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
if debug:
|
||||
console.print("[dim] [DEBUG] Page load event timeout, continuing anyway[/dim]")
|
||||
console.print(f"[dim] [DEBUG] Page load event timeout/error ({type(e).__name__}), continuing anyway[/dim]")
|
||||
logging.debug("Page load event timeout/error: %s", e)
|
||||
|
||||
# Wait for content script to inject
|
||||
await asyncio.sleep(2)
|
||||
|
||||
@@ -62,11 +62,22 @@ def realtime_connect(
|
||||
session_id = f"realtime-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# Build WebSocket URL
|
||||
base = api_base or os.environ.get("OPENAI_API_BASE", "wss://api.openai.com")
|
||||
if api_base:
|
||||
base = api_base
|
||||
else:
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint(default_base="wss://api.openai.com")
|
||||
base = ep.base_url
|
||||
|
||||
if base.startswith("http"):
|
||||
base = base.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
url = f"{base.rstrip('/')}/v1/realtime?model={model}"
|
||||
# Strip any existing /v1 path to avoid double /v1/v1/realtime
|
||||
base = base.rstrip('/')
|
||||
if base.endswith('/v1'):
|
||||
base = base[:-3]
|
||||
|
||||
url = f"{base}/v1/realtime?model={model}"
|
||||
|
||||
return RealtimeSession(
|
||||
id=session_id,
|
||||
|
||||
@@ -363,6 +363,74 @@ def bot_whatsapp(
|
||||
)
|
||||
|
||||
|
||||
@app.command("linear")
|
||||
def bot_linear(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="Linear OAuth token", envvar="LINEAR_OAUTH_TOKEN"),
|
||||
signing_secret: Optional[str] = typer.Option(None, "--signing-secret", help="Linear webhook signing secret", envvar="LINEAR_WEBHOOK_SECRET"),
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Webhook server port"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent YAML configuration file"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
browser: bool = typer.Option(False, "--browser", help="Enable browser control"),
|
||||
browser_profile: str = typer.Option("default", "--browser-profile", help="Browser profile name"),
|
||||
browser_headless: bool = typer.Option(False, "--browser-headless", help="Run browser headless"),
|
||||
tools: Optional[List[str]] = typer.Option(None, "--tools", help="Tools to enable"),
|
||||
skills: Optional[List[str]] = typer.Option(None, "--skills", help="Skills to enable"),
|
||||
skills_dir: Optional[str] = typer.Option(None, "--skills-dir", help="Custom skills directory"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
memory_provider: str = typer.Option("default", "--memory-provider", help="Memory provider"),
|
||||
knowledge: bool = typer.Option(False, "--knowledge", help="Enable knowledge/RAG"),
|
||||
knowledge_sources: Optional[List[str]] = typer.Option(None, "--knowledge-sources", help="Knowledge sources"),
|
||||
web_search: bool = typer.Option(False, "--web", "--web-search", help="Enable web search"),
|
||||
web_provider: str = typer.Option("duckduckgo", "--web-provider", help="Web search provider"),
|
||||
sandbox: bool = typer.Option(False, "--sandbox", help="Enable sandbox mode"),
|
||||
exec_enabled: bool = typer.Option(False, "--exec", help="Enable exec tool"),
|
||||
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions"),
|
||||
session_id: Optional[str] = typer.Option(None, "--session-id", help="Session ID"),
|
||||
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
thinking: Optional[str] = typer.Option(None, "--thinking", help="Thinking mode (off, minimal, low, medium, high)"),
|
||||
):
|
||||
"""Start a Linear bot with full agent capabilities.
|
||||
|
||||
Handles Linear AgentSession webhooks for issue mentions and assignments.
|
||||
|
||||
Examples:
|
||||
praisonai bot linear --token $LINEAR_OAUTH_TOKEN --signing-secret $LINEAR_WEBHOOK_SECRET
|
||||
praisonai bot linear --agent agents.yaml --memory --web --tools linear_tools
|
||||
"""
|
||||
from ..features.bots_cli import BotHandler, BotCapabilities
|
||||
|
||||
capabilities = BotCapabilities(
|
||||
model=model,
|
||||
browser=browser,
|
||||
browser_profile=browser_profile,
|
||||
browser_headless=browser_headless,
|
||||
tools=tools or [],
|
||||
skills=skills or [],
|
||||
skills_dir=skills_dir,
|
||||
memory=memory,
|
||||
memory_provider=memory_provider,
|
||||
knowledge=knowledge,
|
||||
knowledge_sources=knowledge_sources or [],
|
||||
web_search=web_search,
|
||||
web_search_provider=web_provider,
|
||||
sandbox=sandbox,
|
||||
exec_enabled=exec_enabled,
|
||||
auto_approve=auto_approve,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
handler = BotHandler()
|
||||
handler.start_linear(
|
||||
token=token,
|
||||
signing_secret=signing_secret,
|
||||
webhook_port=port,
|
||||
agent_file=agent,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
@app.command("email")
|
||||
def bot_email(
|
||||
token: Optional[str] = typer.Option(None, "--token", "-t", help="Email app password", envvar="EMAIL_APP_PASSWORD"),
|
||||
@@ -502,6 +570,7 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
[green]discord[/green] Discord Bot API
|
||||
[green]slack[/green] Slack Socket Mode
|
||||
[green]whatsapp[/green] WhatsApp Cloud API or Web mode (QR scan)
|
||||
[green]linear[/green] Linear AgentSession webhooks
|
||||
[green]email[/green] Email via IMAP/SMTP
|
||||
[green]agentmail[/green] AgentMail API (API-first email for AI agents)
|
||||
|
||||
@@ -531,6 +600,7 @@ Start a bot on any platform with: praisonai bot <platform>
|
||||
praisonai bot discord --tools DuckDuckGoTool --memory --model gpt-4o
|
||||
praisonai bot whatsapp --token $WHATSAPP_ACCESS_TOKEN --phone-id $WHATSAPP_PHONE_NUMBER_ID
|
||||
praisonai bot whatsapp --mode web
|
||||
praisonai bot linear --token $LINEAR_OAUTH_TOKEN --signing-secret $LINEAR_WEBHOOK_SECRET
|
||||
"""
|
||||
try:
|
||||
from rich import print as rprint
|
||||
|
||||
@@ -434,8 +434,8 @@ class AgentSchedulerHandler:
|
||||
hours = int(uptime_delta.total_seconds() // 3600)
|
||||
minutes = int((uptime_delta.total_seconds() % 3600) // 60)
|
||||
uptime = f"{hours}h {minutes}m"
|
||||
except:
|
||||
pass
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug("Failed to parse started_at timestamp '%s': %s", started_at, e)
|
||||
|
||||
# Display detailed info
|
||||
print(f"\n{'='*60}")
|
||||
|
||||
@@ -557,6 +557,74 @@ class BotHandler:
|
||||
except Exception:
|
||||
pass # neonize Go threads may already be gone
|
||||
|
||||
def start_linear(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
signing_secret: Optional[str] = None,
|
||||
webhook_port: int = 8080,
|
||||
agent_file: Optional[str] = None,
|
||||
capabilities: Optional[BotCapabilities] = None,
|
||||
agent_config_dict: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Start a Linear bot.
|
||||
|
||||
Args:
|
||||
token: Linear OAuth token (or LINEAR_OAUTH_TOKEN env var)
|
||||
signing_secret: Webhook signing secret (or LINEAR_WEBHOOK_SECRET env var)
|
||||
webhook_port: Port for webhook server (default 8080)
|
||||
agent_file: Optional path to agent configuration file
|
||||
capabilities: Optional capabilities configuration
|
||||
agent_config_dict: Optional agent config dict from bot YAML
|
||||
"""
|
||||
self._load_dotenv()
|
||||
|
||||
# Environment variable fallbacks
|
||||
token = token or os.environ.get("LINEAR_OAUTH_TOKEN") or os.environ.get("LINEAR_API_KEY")
|
||||
signing_secret = signing_secret or os.environ.get("LINEAR_WEBHOOK_SECRET", "")
|
||||
|
||||
if not token:
|
||||
print("Error: Linear token required")
|
||||
print("Provide via --token or LINEAR_OAUTH_TOKEN environment variable")
|
||||
print("For personal API key, set LINEAR_API_KEY instead")
|
||||
return
|
||||
|
||||
if not signing_secret:
|
||||
print("Warning: LINEAR_WEBHOOK_SECRET not set - webhook signatures will not be verified")
|
||||
|
||||
# Set auto-approve if enabled
|
||||
if capabilities and capabilities.auto_approve:
|
||||
os.environ["PRAISONAI_AUTO_APPROVE"] = "true"
|
||||
logger.info("Auto-approve enabled for all tool executions")
|
||||
|
||||
try:
|
||||
from praisonai.bots import LinearBot
|
||||
except ImportError as e:
|
||||
print(f"Error: LinearBot import failed. {e}")
|
||||
print("Install with: pip install aiohttp")
|
||||
return
|
||||
|
||||
agent = self._load_agent(agent_file, capabilities, agent_config_dict=agent_config_dict)
|
||||
bot = LinearBot(
|
||||
token=token,
|
||||
agent=agent,
|
||||
signing_secret=signing_secret,
|
||||
webhook_port=webhook_port,
|
||||
)
|
||||
|
||||
self._print_startup_info("Linear", capabilities)
|
||||
print(f"Webhook server on port {webhook_port}")
|
||||
print(f"Webhook endpoint: http://0.0.0.0:{webhook_port}/webhook")
|
||||
if signing_secret:
|
||||
print("Webhook signature verification: enabled")
|
||||
else:
|
||||
print("Webhook signature verification: disabled (set LINEAR_WEBHOOK_SECRET)")
|
||||
|
||||
try:
|
||||
asyncio.run(bot.start())
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bot...")
|
||||
asyncio.run(bot.stop())
|
||||
|
||||
def start_email(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
|
||||
@@ -294,8 +294,30 @@ class JobWorkflowExecutor:
|
||||
return {"ok": True, "output": result.stdout.strip()}
|
||||
|
||||
def _exec_inline_python(self, code: str, step: Dict, flags: Dict) -> Dict:
|
||||
"""Execute inline Python code in an isolated namespace."""
|
||||
_safe_builtins = {
|
||||
"""Execute Python code safely using AST validation.
|
||||
|
||||
Supports both expressions and limited statements for backward compatibility.
|
||||
"""
|
||||
import ast
|
||||
|
||||
# Allowed AST node types for safe execution
|
||||
_ALLOWED_NODES = (
|
||||
# Expressions
|
||||
ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp, ast.Compare,
|
||||
ast.IfExp, ast.Constant, ast.Name, ast.Load, ast.Store, ast.Subscript,
|
||||
ast.List, ast.Tuple, ast.Dict, ast.Set,
|
||||
ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Lt, ast.LtE,
|
||||
ast.Gt, ast.GtE, ast.In, ast.NotIn, ast.Add, ast.Sub, ast.Mult,
|
||||
ast.Div, ast.Mod, ast.FloorDiv, ast.USub, ast.UAdd,
|
||||
# Calls and attribute access (security enforced via safe builtins whitelist)
|
||||
ast.Call, ast.Attribute, ast.keyword, ast.Starred,
|
||||
# Limited statements for backward compatibility
|
||||
ast.Module, ast.Assign, ast.Import, ast.ImportFrom,
|
||||
ast.alias,
|
||||
)
|
||||
|
||||
# Safe builtins whitelist — no __import__, exec, eval, open, compile
|
||||
_SAFE_BUILTINS = {
|
||||
"True": True, "False": False, "None": None,
|
||||
"int": int, "float": float, "str": str, "bool": bool,
|
||||
"list": list, "dict": dict, "tuple": tuple, "set": set,
|
||||
@@ -305,20 +327,64 @@ class JobWorkflowExecutor:
|
||||
"min": min, "max": max, "sum": sum, "abs": abs, "round": round,
|
||||
"isinstance": isinstance, "type": type,
|
||||
"print": print, "repr": repr,
|
||||
"hasattr": hasattr, "getattr": getattr, "setattr": setattr,
|
||||
"hasattr": hasattr, "getattr": getattr,
|
||||
}
|
||||
|
||||
# Safe namespace with whitelisted environment variables only
|
||||
safe_env = {
|
||||
k: v for k, v in os.environ.items()
|
||||
if k.startswith(('PRAISON', 'HOME', 'USER', 'PATH')) and
|
||||
not k.upper().endswith(('KEY', 'SECRET', 'TOKEN', 'PASSWORD'))
|
||||
}
|
||||
|
||||
namespace = {
|
||||
"flags": flags,
|
||||
"vars": {k: self._resolve_var_value(v) for k, v in self._vars.items()},
|
||||
"env": os.environ.copy(),
|
||||
"env": safe_env, # Whitelisted env vars only
|
||||
"cwd": self._cwd,
|
||||
"__builtins__": _safe_builtins,
|
||||
"__builtins__": _SAFE_BUILTINS,
|
||||
}
|
||||
|
||||
try:
|
||||
exec(code, namespace)
|
||||
return {"ok": True, "output": namespace.get("result", "")}
|
||||
# First try parsing as expression
|
||||
try:
|
||||
tree = ast.parse(code.strip(), mode="eval")
|
||||
is_expression = True
|
||||
except SyntaxError:
|
||||
# Fall back to exec mode for statements
|
||||
tree = ast.parse(code.strip(), mode="exec")
|
||||
is_expression = False
|
||||
|
||||
# Validate all nodes are in allowlist
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, _ALLOWED_NODES):
|
||||
return {"ok": False, "error": f"Disallowed code node: {type(node).__name__}. Only safe expressions and limited statements are allowed."}
|
||||
|
||||
# Additional safety checks for imports
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
# Only allow standard library modules commonly used in workflows
|
||||
allowed_modules = {'os', 'datetime', 'time', 'json', 'math', 're', 'collections', 'itertools'}
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.split('.')[0] not in allowed_modules:
|
||||
return {"ok": False, "error": f"Import of '{alias.name}' not allowed. Only standard library modules are permitted."}
|
||||
elif node.module and node.module.split('.')[0] not in allowed_modules:
|
||||
return {"ok": False, "error": f"Import of '{node.module}' not allowed. Only standard library modules are permitted."}
|
||||
|
||||
# Execute within safe namespace
|
||||
if is_expression:
|
||||
result = eval(compile(tree, "<workflow>", "eval"), namespace)
|
||||
return {"ok": True, "output": str(result)}
|
||||
else:
|
||||
exec(compile(tree, "<workflow>", "exec"), namespace)
|
||||
# Return the 'result' variable if it was set (backward compatibility)
|
||||
result = namespace.get("result", "")
|
||||
return {"ok": True, "output": str(result)}
|
||||
|
||||
except SyntaxError as e:
|
||||
return {"ok": False, "error": f"Syntax error: {e}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": False, "error": f"Evaluation error: {e}"}
|
||||
|
||||
def _exec_action(self, action_name: str, step: Dict, flags: Dict) -> Dict:
|
||||
"""
|
||||
|
||||
@@ -5,9 +5,12 @@ Provides token usage and cost tracking for agent executions.
|
||||
Usage: praisonai "prompt" --metrics
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Tuple
|
||||
from .base import FlagHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetricsHandler(FlagHandler):
|
||||
"""
|
||||
@@ -133,33 +136,29 @@ class MetricsHandler(FlagHandler):
|
||||
ctx = litellm._thread_context
|
||||
if hasattr(ctx, 'usage'):
|
||||
usage = ctx.usage
|
||||
if hasattr(usage, 'prompt_tokens'):
|
||||
metrics['prompt_tokens'] = usage.prompt_tokens
|
||||
if hasattr(usage, 'completion_tokens'):
|
||||
metrics['completion_tokens'] = usage.completion_tokens
|
||||
if hasattr(usage, 'total_tokens'):
|
||||
metrics['total_tokens'] = usage.total_tokens
|
||||
except:
|
||||
pass
|
||||
metrics['prompt_tokens'] = getattr(usage, 'prompt_tokens', None)
|
||||
metrics['completion_tokens'] = getattr(usage, 'completion_tokens', None)
|
||||
metrics['total_tokens'] = getattr(usage, 'total_tokens', None)
|
||||
except ImportError:
|
||||
logger.debug("litellm not installed; skipping token metrics")
|
||||
except AttributeError as e:
|
||||
logger.debug("litellm internals changed (%s); skipping token metrics", e)
|
||||
|
||||
# Try to get cost from litellm cost tracking
|
||||
try:
|
||||
import litellm
|
||||
from litellm import completion_cost
|
||||
# If we have token counts, estimate cost
|
||||
if 'prompt_tokens' in metrics and 'completion_tokens' in metrics:
|
||||
if metrics.get('prompt_tokens') and metrics.get('completion_tokens'):
|
||||
model = metrics.get('model', 'gpt-4o-mini')
|
||||
try:
|
||||
cost = completion_cost(
|
||||
metrics['cost'] = completion_cost(
|
||||
model=model,
|
||||
prompt_tokens=metrics['prompt_tokens'],
|
||||
completion_tokens=metrics['completion_tokens']
|
||||
completion_tokens=metrics['completion_tokens'],
|
||||
)
|
||||
metrics['cost'] = cost
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cost calc failed for model=%s: %s", model, e)
|
||||
except ImportError:
|
||||
logger.debug("litellm.completion_cost unavailable; skipping cost metrics")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ import yaml
|
||||
import time
|
||||
from rich import print
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
@@ -110,6 +109,11 @@ _BLOCKED_ENV_KEYS = frozenset({
|
||||
_BLOCKED_ENV_KEYS_UPPER = frozenset(k.upper() for k in _BLOCKED_ENV_KEYS)
|
||||
|
||||
|
||||
def _load_env_once():
|
||||
"""Load environment variables from .env file once at CLI startup."""
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _validate_env_key(key) -> None:
|
||||
"""Raise ``ValueError`` if *key* is a blocked environment variable name.
|
||||
|
||||
@@ -268,29 +272,26 @@ class PraisonAI:
|
||||
self.agent_yaml = agent_yaml
|
||||
self._interactive_mode = False # Flag for interactive TUI mode
|
||||
# Create config_list with AutoGen compatibility
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
# Priority order: MODEL_NAME > OPENAI_MODEL_NAME for model selection
|
||||
model_name = os.environ.get("MODEL_NAME") or os.environ.get("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
# Priority order for base_url: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE
|
||||
# OPENAI_BASE_URL is the standard OpenAI SDK environment variable
|
||||
base_url = (
|
||||
os.environ.get("OPENAI_BASE_URL") or
|
||||
os.environ.get("OPENAI_API_BASE") or
|
||||
os.environ.get("OLLAMA_API_BASE", "https://api.openai.com/v1")
|
||||
)
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
self.config_list = [
|
||||
{
|
||||
'model': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
'api_type': 'openai' # AutoGen expects this field
|
||||
}
|
||||
]
|
||||
self.agent_file = agent_file
|
||||
self.framework = framework
|
||||
|
||||
# Validate framework availability early to fail fast
|
||||
if self.framework:
|
||||
from praisonai.framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(self.framework)
|
||||
|
||||
self.auto = auto
|
||||
self.init = init
|
||||
self.tools = tools or [] # Store tool class names as a list
|
||||
@@ -348,6 +349,9 @@ class PraisonAI:
|
||||
initializes the necessary attributes, and then calls the appropriate methods based on the
|
||||
provided arguments.
|
||||
"""
|
||||
# Load environment variables from .env file
|
||||
_load_env_once()
|
||||
|
||||
# Warning filters now installed via Typer callback for CLI-only usage
|
||||
|
||||
# Telemetry defaults now handled in PraisonAI.__init__ with Langfuse awareness
|
||||
@@ -376,6 +380,11 @@ class PraisonAI:
|
||||
|
||||
self.framework = args.framework or self.framework
|
||||
|
||||
# Validate framework availability early to fail fast
|
||||
if self.framework:
|
||||
from praisonai.framework_adapters.validators import assert_framework_available
|
||||
assert_framework_available(self.framework)
|
||||
|
||||
# Update config_list model if --model flag is provided
|
||||
if getattr(args, 'model', None):
|
||||
self.config_list[0]['model'] = args.model
|
||||
@@ -1939,16 +1948,16 @@ class PraisonAI:
|
||||
# Load from file
|
||||
try:
|
||||
import inspect
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("rewrite_tools_module", rewrite_tools)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
from .._safe_loader import load_user_module
|
||||
module = load_user_module(rewrite_tools, name="rewrite_tools_module")
|
||||
if module is not None:
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isfunction(obj) and not name.startswith('_'):
|
||||
rewrite_tools_list.append(obj)
|
||||
if rewrite_tools_list:
|
||||
print(f"[cyan]Loaded {len(rewrite_tools_list)} tools for query rewriter[/cyan]")
|
||||
else:
|
||||
print(f"[yellow]Warning: Rewrite tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.[/yellow]")
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load rewrite tools: {e}[/yellow]")
|
||||
else:
|
||||
@@ -2031,16 +2040,16 @@ class PraisonAI:
|
||||
# Load from file
|
||||
try:
|
||||
import inspect
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("expand_tools_module", expand_tools)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
from .._safe_loader import load_user_module
|
||||
module = load_user_module(expand_tools, name="expand_tools_module")
|
||||
if module is not None:
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isfunction(obj) and not name.startswith('_'):
|
||||
expand_tools_list.append(obj)
|
||||
if expand_tools_list:
|
||||
print(f"[cyan]Loaded {len(expand_tools_list)} tools for prompt expander[/cyan]")
|
||||
else:
|
||||
print(f"[yellow]Warning: Expand tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.[/yellow]")
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load expand tools: {e}[/yellow]")
|
||||
else:
|
||||
@@ -2116,15 +2125,16 @@ class PraisonAI:
|
||||
# Load from file
|
||||
try:
|
||||
import inspect
|
||||
spec = importlib.util.spec_from_file_location("tools_module", tools_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
from .._safe_loader import load_user_module
|
||||
module = load_user_module(tools_path, name="tools_module")
|
||||
if module is not None:
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isfunction(obj) and not name.startswith('_'):
|
||||
tools_list.append(obj)
|
||||
if tools_list:
|
||||
print(f"[cyan]Loaded {len(tools_list)} tools from {tools_path}[/cyan]")
|
||||
else:
|
||||
print(f"[yellow]Warning: Tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.[/yellow]")
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load tools from {tools_path}: {e}[/yellow]")
|
||||
else:
|
||||
@@ -2752,14 +2762,16 @@ class PraisonAI:
|
||||
|
||||
if tools_file.exists():
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("recipe_tools", str(tools_file))
|
||||
tools_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(tools_module)
|
||||
|
||||
# Build registry from public callable functions
|
||||
for name, obj in vars(tools_module).items():
|
||||
if callable(obj) and not name.startswith('_'):
|
||||
tool_registry[name] = obj
|
||||
from .._safe_loader import load_user_module
|
||||
tools_module = load_user_module(str(tools_file), name="recipe_tools")
|
||||
if tools_module is not None:
|
||||
import inspect
|
||||
# Build registry from public functions only
|
||||
for name, obj in vars(tools_module).items():
|
||||
if inspect.isfunction(obj) and not name.startswith('_') and inspect.getmodule(obj) is tools_module:
|
||||
tool_registry[name] = obj
|
||||
else:
|
||||
logging.getLogger(__name__).warning("Recipe tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.")
|
||||
|
||||
if tool_registry:
|
||||
print(f"[cyan]Loaded {len(tool_registry)} tools from tools.py: {', '.join(tool_registry.keys())}[/cyan]")
|
||||
@@ -5381,16 +5393,17 @@ Now, {final_instruction.lower()}:"""
|
||||
# Load from file
|
||||
try:
|
||||
import inspect
|
||||
spec = importlib.util.spec_from_file_location("tools_module", tools_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
from .._safe_loader import load_user_module
|
||||
module = load_user_module(tools_path, name="tools_module")
|
||||
if module is not None:
|
||||
# Get all callable functions from the module
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isfunction(obj) and not name.startswith('_'):
|
||||
tools_list.append(obj)
|
||||
if tools_list:
|
||||
print(f"[cyan]Loaded {len(tools_list)} tools from {tools_path}[/cyan]")
|
||||
else:
|
||||
print(f"[yellow]Warning: Tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.[/yellow]")
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load tools from {tools_path}: {e}[/yellow]")
|
||||
else:
|
||||
|
||||
@@ -97,5 +97,4 @@ def __getattr__(name: str):
|
||||
_register_builtin_backends()
|
||||
raise AttributeError(f"No attribute {name}")
|
||||
|
||||
# Trigger registration on import
|
||||
_register_builtin_backends()
|
||||
# Registration happens lazily via __getattr__ on first attribute access
|
||||
@@ -290,10 +290,7 @@ class PraisonAIDB:
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a new run (turn) starts."""
|
||||
if not self._conversation_store:
|
||||
return
|
||||
|
||||
# Store run start in state if available
|
||||
# Store run start in state if available (even without conversation store)
|
||||
if self._state_store:
|
||||
run_key = f"run:{session_id}:{run_id}"
|
||||
self._state_store.set(run_key, {
|
||||
@@ -304,6 +301,10 @@ class PraisonAIDB:
|
||||
"status": "running",
|
||||
"metadata": metadata or {}
|
||||
})
|
||||
|
||||
# Early return only applies to conversation store operations
|
||||
if not self._conversation_store:
|
||||
return
|
||||
|
||||
def on_run_end(
|
||||
self,
|
||||
@@ -332,10 +333,33 @@ class PraisonAIDB:
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
) -> List:
|
||||
"""Get runs for a session."""
|
||||
# For now, runs are stored in state store
|
||||
# Full implementation would query from conversation store
|
||||
return []
|
||||
"""Return persisted runs for a session, newest first."""
|
||||
self._init_stores()
|
||||
if not self._state_store:
|
||||
logger.warning("get_runs() called but no state_url configured; returning []")
|
||||
return []
|
||||
|
||||
prefix = f"run:{session_id}:"
|
||||
# State stores expose scan_prefix() per the protocol; fall back to keys() for legacy.
|
||||
if hasattr(self._state_store, "scan_prefix"):
|
||||
keys = self._state_store.scan_prefix(prefix)
|
||||
else:
|
||||
keys = [k for k in self._state_store.keys() if k.startswith(prefix)]
|
||||
|
||||
runs = []
|
||||
for k in keys:
|
||||
try:
|
||||
run_data = self._state_store.get(k)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load run key '%s': %s", k, exc)
|
||||
continue
|
||||
if isinstance(run_data, dict):
|
||||
runs.append(run_data)
|
||||
|
||||
runs.sort(key=lambda r: r.get("started_at", 0), reverse=True)
|
||||
if limit is not None:
|
||||
return runs[: max(limit, 0)]
|
||||
return runs
|
||||
|
||||
def export_session(
|
||||
self,
|
||||
@@ -499,9 +523,36 @@ class PraisonAIDB:
|
||||
user_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List:
|
||||
"""Get traces with optional filters."""
|
||||
# For now, return empty list - full implementation would scan state store
|
||||
return []
|
||||
"""Return persisted traces, optionally filtered by session/user."""
|
||||
self._init_stores()
|
||||
if not self._state_store:
|
||||
logger.warning("get_traces() called but no state_url configured; returning []")
|
||||
return []
|
||||
|
||||
prefix = "trace:"
|
||||
if hasattr(self._state_store, "scan_prefix"):
|
||||
keys = self._state_store.scan_prefix(prefix)
|
||||
else:
|
||||
keys = [k for k in self._state_store.keys() if k.startswith(prefix)]
|
||||
|
||||
traces = []
|
||||
for k in keys:
|
||||
try:
|
||||
trace_data = self._state_store.get(k)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load trace key '%s': %s", k, exc)
|
||||
continue
|
||||
if isinstance(trace_data, dict):
|
||||
traces.append(trace_data)
|
||||
|
||||
if session_id is not None:
|
||||
traces = [t for t in traces if t.get("session_id") == session_id]
|
||||
if user_id is not None:
|
||||
traces = [t for t in traces if t.get("user_id") == user_id]
|
||||
traces.sort(key=lambda t: t.get("started_at", 0), reverse=True)
|
||||
if limit is not None:
|
||||
return traces[: max(limit, 0)]
|
||||
return traces
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all database connections."""
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.32 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.37 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
@@ -92,9 +92,12 @@ class CloudDeployer:
|
||||
|
||||
def set_environment_variables(self):
|
||||
"""Sets environment variables with fallback to .env values or defaults."""
|
||||
os.environ["OPENAI_MODEL_NAME"] = os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini")
|
||||
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "Enter your API key")
|
||||
os.environ["OPENAI_API_BASE"] = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
os.environ["OPENAI_MODEL_NAME"] = ep.model
|
||||
os.environ["OPENAI_API_KEY"] = ep.api_key or "Enter your API key"
|
||||
os.environ["OPENAI_API_BASE"] = ep.base_url
|
||||
|
||||
def run_commands(self):
|
||||
"""
|
||||
@@ -129,9 +132,11 @@ class CloudDeployer:
|
||||
return
|
||||
|
||||
# Get environment variables
|
||||
openai_model = os.environ.get('OPENAI_MODEL_NAME', 'gpt-5-nano')
|
||||
openai_key = os.environ.get('OPENAI_API_KEY', 'Enter your API key')
|
||||
openai_base = os.environ.get('OPENAI_API_BASE', 'https://api.openai.com/v1')
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
openai_model = ep.model
|
||||
openai_key = ep.api_key or 'Enter your API key'
|
||||
openai_base = ep.base_url
|
||||
|
||||
# Build commands with actual values
|
||||
commands = [
|
||||
|
||||
@@ -30,23 +30,36 @@ from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from praisonai import PraisonAI
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# CORS configuration
|
||||
{"CORS(app)" if config.cors_enabled else "# CORS disabled"}
|
||||
|
||||
# Authentication
|
||||
AUTH_ENABLED = {config.auth_enabled}
|
||||
AUTH_TOKEN = {repr(config.auth_token)}
|
||||
# Authentication. Defaults are taken from the deploy config but can be
|
||||
# overridden at runtime via env vars so operators can rotate the bearer
|
||||
# token without regenerating this file.
|
||||
AUTH_ENABLED = os.environ.get("PRAISONAI_API_AUTH", "{'enabled' if config.auth_enabled else 'disabled'}").strip().lower() != "disabled"
|
||||
AUTH_TOKEN = os.environ.get("PRAISONAI_API_TOKEN") or {repr(config.auth_token)}
|
||||
|
||||
if AUTH_ENABLED and not AUTH_TOKEN:
|
||||
AUTH_TOKEN = secrets.token_urlsafe(32)
|
||||
print(
|
||||
f"[praisonai-api] generated API token (set PRAISONAI_API_TOKEN to override): {{AUTH_TOKEN}}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def check_auth():
|
||||
"""Check authentication if enabled."""
|
||||
"""Check authentication if enabled (constant-time compare)."""
|
||||
if not AUTH_ENABLED:
|
||||
return True
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
return token == AUTH_TOKEN
|
||||
if not AUTH_TOKEN:
|
||||
return False
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '', 1)
|
||||
return secrets.compare_digest(token, AUTH_TOKEN)
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
|
||||
@@ -26,7 +26,7 @@ class APIConfig(BaseModel):
|
||||
port: int = Field(default=8005, description="Server port")
|
||||
workers: int = Field(default=1, description="Number of worker processes")
|
||||
cors_enabled: bool = Field(default=True, description="Enable CORS")
|
||||
auth_enabled: bool = Field(default=False, description="Enable authentication")
|
||||
auth_enabled: bool = Field(default=True, description="Enable authentication")
|
||||
auth_token: Optional[str] = Field(default=None, description="Authentication token")
|
||||
reload: bool = Field(default=False, description="Enable auto-reload for development")
|
||||
|
||||
|
||||
@@ -109,11 +109,11 @@ agents:
|
||||
deploy_yaml = """deploy:
|
||||
type: api
|
||||
api:
|
||||
host: 0.0.0.0
|
||||
host: 127.0.0.1
|
||||
port: 8005
|
||||
workers: 1
|
||||
cors_enabled: true
|
||||
auth_enabled: false
|
||||
auth_enabled: true
|
||||
"""
|
||||
|
||||
elif deploy_type == DeployType.DOCKER:
|
||||
|
||||
@@ -15,6 +15,8 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
"""Adapter for AutoGen v0.2 framework."""
|
||||
|
||||
name = "autogen"
|
||||
install_hint = 'pip install "praisonai[autogen]"' # v0.2 only
|
||||
requires_tools_extra = True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if AutoGen v0.2 is available for import."""
|
||||
@@ -36,9 +38,8 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise ImportError("AutoGen v0.2 is not available. Install with: pip install autogen")
|
||||
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
# Import AutoGen only when needed
|
||||
import autogen
|
||||
|
||||
@@ -91,22 +92,14 @@ class AutoGenAdapter(BaseFrameworkAdapter):
|
||||
logger.info("AutoGen v0.2 execution completed")
|
||||
return result
|
||||
|
||||
def _format_template(self, template: str, **kwargs) -> str:
|
||||
"""Safely format template string with given kwargs."""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(f"Template formatting failed for key {e}, returning original template")
|
||||
return template
|
||||
except Exception as e:
|
||||
logger.warning(f"Template formatting error: {e}, returning original template")
|
||||
return template
|
||||
|
||||
|
||||
class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
"""Adapter for AutoGen v0.4 framework."""
|
||||
|
||||
name = "autogen_v4"
|
||||
install_hint = 'pip install "praisonai[autogen-v4]"'
|
||||
requires_tools_extra = True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if AutoGen v0.4 is available for import."""
|
||||
@@ -129,9 +122,8 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise ImportError("AutoGen v0.4 is not available. Install with: pip install autogen-agentchat autogen-ext")
|
||||
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
logger.info("Starting AutoGen v0.4 execution...")
|
||||
# For now, return a proper error message instead of delegating
|
||||
# TODO: Implement full AutoGen v0.4 adapter logic
|
||||
@@ -143,6 +135,8 @@ class AG2Adapter(BaseFrameworkAdapter):
|
||||
"""Adapter for AG2 framework."""
|
||||
|
||||
name = "ag2"
|
||||
install_hint = 'pip install "praisonai[ag2]"'
|
||||
requires_tools_extra = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if AG2 is available for import."""
|
||||
@@ -166,8 +160,7 @@ class AG2Adapter(BaseFrameworkAdapter):
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise ImportError("AG2 is not available. Install with: pip install ag2")
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
logger.info("Starting AG2 execution...")
|
||||
# For now, return a proper error message instead of delegating
|
||||
|
||||
@@ -5,7 +5,7 @@ This module defines the protocol that all framework adapters must implement,
|
||||
enabling lazy-loaded, protocol-driven framework support.
|
||||
"""
|
||||
|
||||
from typing import Protocol, Dict, List, Any, Optional
|
||||
from typing import Protocol, Dict, List, Any, Optional, Callable
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
@@ -13,12 +13,24 @@ class FrameworkAdapter(Protocol):
|
||||
"""Protocol for framework adapters."""
|
||||
|
||||
name: str
|
||||
install_hint: str
|
||||
requires_tools_extra: bool
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if the framework is available for import."""
|
||||
...
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback: Optional[Callable] = None,
|
||||
task_callback: Optional[Callable] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run the framework with given configuration.
|
||||
|
||||
@@ -26,6 +38,10 @@ class FrameworkAdapter(Protocol):
|
||||
config: Framework configuration
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
@@ -55,6 +71,22 @@ class BaseFrameworkAdapter:
|
||||
"""List all registered tool names."""
|
||||
return list(self._tool_registry.keys())
|
||||
|
||||
def _format_template(self, template: str, **kwargs) -> str:
|
||||
"""Safely format template string with given kwargs."""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
# Import logger here to avoid circular imports
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("Template formatting failed for key %s; returning original template", e)
|
||||
return template
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("Template formatting error: %s; returning original template", e)
|
||||
return template
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources - default implementation does nothing."""
|
||||
pass
|
||||
|
||||
@@ -5,7 +5,7 @@ Provides lazy-loaded, scoped integration with CrewAI framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
from .base import BaseFrameworkAdapter, scoped_telemetry_disable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,6 +15,8 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
"""Adapter for CrewAI framework with scoped telemetry disabling."""
|
||||
|
||||
name = "crewai"
|
||||
install_hint = 'pip install "praisonai[crewai]"'
|
||||
requires_tools_extra = True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if CrewAI is available for import."""
|
||||
@@ -24,7 +26,17 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback = None,
|
||||
task_callback = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run CrewAI with given configuration.
|
||||
|
||||
@@ -32,17 +44,21 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
config: CrewAI configuration with agents and tasks
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise ImportError("CrewAI is not available. Install with: pip install crewai")
|
||||
|
||||
# Import CrewAI only when needed
|
||||
# Import CrewAI only when needed (availability already validated at CLI entry)
|
||||
from crewai import Agent, Task, Crew
|
||||
from crewai.telemetry import Telemetry
|
||||
|
||||
# Suppress crewai.cli.config logger (scoped to when CrewAI is actually used)
|
||||
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
|
||||
|
||||
# Use scoped telemetry disabling instead of global patching
|
||||
with scoped_telemetry_disable(Telemetry):
|
||||
# For now, use simplified CrewAI execution
|
||||
@@ -51,13 +67,22 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
|
||||
# Create agents
|
||||
for agent_name, agent_details in config.get('roles', {}).items():
|
||||
# Resolve tools for this agent from tools_dict
|
||||
agent_tool_list = []
|
||||
if tools_dict:
|
||||
agent_tools = agent_details.get('tools', [])
|
||||
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
|
||||
|
||||
agent = Agent(
|
||||
role=agent_details.get('role', agent_name),
|
||||
goal=self._format_template(agent_details.get('goal', ''), topic=topic),
|
||||
backstory=self._format_template(agent_details.get('backstory', ''), topic=topic),
|
||||
tools=agent_tool_list,
|
||||
verbose=True,
|
||||
allow_delegation=agent_details.get('allow_delegation', False)
|
||||
)
|
||||
if agent_callback:
|
||||
agent.step_callback = agent_callback
|
||||
agents[agent_name] = agent
|
||||
|
||||
# Create tasks
|
||||
@@ -68,6 +93,8 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
expected_output=self._format_template(task_details['expected_output'], topic=topic),
|
||||
agent=agents[agent_name]
|
||||
)
|
||||
if task_callback:
|
||||
task.callback = task_callback
|
||||
tasks.append(task)
|
||||
|
||||
# Create and run crew
|
||||
@@ -83,13 +110,3 @@ class CrewAIAdapter(BaseFrameworkAdapter):
|
||||
|
||||
return str(result)
|
||||
|
||||
def _format_template(self, template: str, **kwargs) -> str:
|
||||
"""Safely format template string with given kwargs."""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(f"Template formatting failed for key {e}, returning original template")
|
||||
return template
|
||||
except Exception as e:
|
||||
logger.warning(f"Template formatting error: {e}, returning original template")
|
||||
return template
|
||||
@@ -5,7 +5,7 @@ Provides lazy-loaded integration with the PraisonAI agents framework.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
from .base import BaseFrameworkAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,6 +15,8 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
"""Adapter for PraisonAI agents framework."""
|
||||
|
||||
name = "praisonai"
|
||||
install_hint = 'pip install praisonaiagents'
|
||||
requires_tools_extra = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if PraisonAI agents is available for import."""
|
||||
@@ -24,7 +26,17 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str:
|
||||
def run(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
llm_config: List[Dict],
|
||||
topic: str,
|
||||
*,
|
||||
tools_dict: Optional[Dict[str, Any]] = None,
|
||||
agent_callback = None,
|
||||
task_callback = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Run PraisonAI agents with given configuration.
|
||||
|
||||
@@ -32,26 +44,113 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
config: PraisonAI configuration with agents and tasks
|
||||
llm_config: LLM configuration list
|
||||
topic: Topic for the tasks
|
||||
tools_dict: Available tools dictionary
|
||||
agent_callback: Callback for agent events
|
||||
task_callback: Callback for task events
|
||||
cli_config: CLI configuration
|
||||
|
||||
Returns:
|
||||
Execution result as string
|
||||
"""
|
||||
if not self.is_available():
|
||||
raise ImportError("PraisonAI agents is not available. Install with: pip install praisonaiagents")
|
||||
|
||||
# Availability already validated at CLI entry
|
||||
|
||||
# Import PraisonAI components only when needed
|
||||
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam
|
||||
|
||||
logger.info("Starting PraisonAI execution...")
|
||||
# For now, return a proper error message instead of delegating
|
||||
# TODO: Implement full PraisonAI adapter logic
|
||||
logger.warning("PraisonAI adapter is not yet fully implemented")
|
||||
return "### PraisonAI Output ###\nPraisonAI adapter is not yet fully implemented. Please use 'crewai' framework for similar functionality."
|
||||
|
||||
# Basic implementation - create agents and tasks from config
|
||||
agents = {}
|
||||
tasks = []
|
||||
|
||||
# Get model from llm_config or environment
|
||||
model_name = "gpt-4o-mini"
|
||||
if llm_config and llm_config[0].get('model'):
|
||||
model_name = llm_config[0]['model']
|
||||
|
||||
# Create agents from roles
|
||||
for role, details in config.get('roles', {}).items():
|
||||
role_filled = self._format_template(details.get('role', role), topic=topic)
|
||||
goal_filled = self._format_template(details.get('goal', ''), topic=topic)
|
||||
backstory_filled = self._format_template(details.get('backstory', ''), topic=topic)
|
||||
|
||||
# Resolve tools for this agent from tools_dict
|
||||
agent_tool_list = []
|
||||
if tools_dict:
|
||||
agent_tools = details.get('tools', [])
|
||||
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
|
||||
|
||||
# Create basic agent
|
||||
agent = PraisonAgent(
|
||||
name=role_filled,
|
||||
role=role_filled,
|
||||
goal=goal_filled,
|
||||
backstory=backstory_filled,
|
||||
instructions=details.get('instructions'),
|
||||
llm=model_name,
|
||||
allow_delegation=details.get('allow_delegation', False),
|
||||
tools=agent_tool_list,
|
||||
)
|
||||
|
||||
if agent_callback:
|
||||
agent.step_callback = agent_callback
|
||||
|
||||
agents[role] = agent
|
||||
|
||||
# Create tasks for the agent
|
||||
agent_tasks = details.get('tasks', {})
|
||||
if not agent_tasks:
|
||||
# Auto-generate a task
|
||||
task_description = details.get('instructions') or backstory_filled
|
||||
task = PraisonTask(
|
||||
description=task_description,
|
||||
expected_output="Complete the assigned task successfully.",
|
||||
agent=agent,
|
||||
)
|
||||
if task_callback:
|
||||
task.callback = task_callback
|
||||
tasks.append(task)
|
||||
else:
|
||||
for task_name, task_details in agent_tasks.items():
|
||||
description_filled = self._format_template(
|
||||
task_details['description'], topic=topic
|
||||
)
|
||||
expected_output_filled = self._format_template(
|
||||
task_details['expected_output'], topic=topic
|
||||
)
|
||||
|
||||
task = PraisonTask(
|
||||
description=description_filled,
|
||||
expected_output=expected_output_filled,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
if task_callback:
|
||||
task.callback = task_callback
|
||||
|
||||
tasks.append(task)
|
||||
|
||||
# Create and run the team
|
||||
memory = config.get('memory', False)
|
||||
|
||||
if config.get('process') == 'hierarchical':
|
||||
team = AgentTeam(
|
||||
agents=list(agents.values()),
|
||||
tasks=tasks,
|
||||
process="hierarchical",
|
||||
manager_llm=config.get('manager_llm') or model_name,
|
||||
memory=memory
|
||||
)
|
||||
else:
|
||||
team = AgentTeam(
|
||||
agents=list(agents.values()),
|
||||
tasks=tasks,
|
||||
memory=memory
|
||||
)
|
||||
|
||||
response = team.start()
|
||||
result = f"### PraisonAI Output ###\n{response}" if response else "### PraisonAI Output ###\nTask completed."
|
||||
|
||||
logger.info("PraisonAI execution completed")
|
||||
return result
|
||||
|
||||
def _format_template(self, template: str, **kwargs) -> str:
|
||||
"""Safely format template string with given kwargs."""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning(f"Template formatting failed for key {e}, returning original template")
|
||||
return template
|
||||
except Exception as e:
|
||||
logger.warning(f"Template formatting error: {e}, returning original template")
|
||||
return template
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Framework availability validators.
|
||||
|
||||
Provides early validation of framework availability to fail fast at CLI entry
|
||||
rather than inside run() methods after expensive setup work.
|
||||
"""
|
||||
|
||||
from .registry import FrameworkAdapterRegistry
|
||||
|
||||
|
||||
# Install hints for common frameworks
|
||||
_INSTALL_HINTS = {
|
||||
"crewai": "pip install 'praisonai[crewai]' # or: pip install crewai",
|
||||
"autogen": "pip install 'praisonai[autogen]' # or: pip install pyautogen",
|
||||
"praisonai": "pip install praisonaiagents",
|
||||
}
|
||||
|
||||
|
||||
def assert_framework_available(name: str) -> None:
|
||||
"""
|
||||
Raise ImportError immediately if the chosen framework is missing.
|
||||
|
||||
Args:
|
||||
name: Framework name to validate
|
||||
|
||||
Raises:
|
||||
ImportError: If framework is not available with actionable install hint
|
||||
"""
|
||||
registry = FrameworkAdapterRegistry.get_instance()
|
||||
|
||||
if not registry.is_available(name):
|
||||
hint = _INSTALL_HINTS.get(name, f"pip install {name}")
|
||||
raise ImportError(
|
||||
f"Framework '{name}' was requested but is not installed.\n"
|
||||
f"Install it with:\n {hint}"
|
||||
)
|
||||
@@ -89,6 +89,12 @@ def create_pairing_routes(pairing_store: PairingStore, auth_checker: callable, r
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonaiagents.bus import get_default_bus, Event
|
||||
get_default_bus().publish_event(Event(type="pairing_approved", data={"channel": channel, "code": code}))
|
||||
except Exception as _bus_exc:
|
||||
logger.debug("EventBus emit skipped: %s", _bus_exc)
|
||||
|
||||
return JSONResponse({
|
||||
"approved": True,
|
||||
"channel": channel,
|
||||
|
||||
@@ -1694,6 +1694,16 @@ class WebSocketGateway:
|
||||
mode=wa_mode,
|
||||
creds_dir=ch_cfg.get("creds_dir"),
|
||||
)
|
||||
elif channel_type == "linear":
|
||||
from praisonai.bots import LinearBot
|
||||
linear_token = token or os.environ.get("LINEAR_OAUTH_TOKEN", "") or os.environ.get("LINEAR_API_KEY", "")
|
||||
return LinearBot(
|
||||
token=linear_token,
|
||||
agent=agent,
|
||||
config=config,
|
||||
signing_secret=ch_cfg.get("signing_secret", "") or os.environ.get("LINEAR_WEBHOOK_SECRET", ""),
|
||||
webhook_port=int(ch_cfg.get("webhook_port", 8080)),
|
||||
)
|
||||
elif channel_type == "email":
|
||||
from praisonai.bots import EmailBot
|
||||
email_token = token or os.environ.get("EMAIL_APP_PASSWORD", "")
|
||||
@@ -1736,8 +1746,8 @@ class WebSocketGateway:
|
||||
# Use the lower-level API instead.
|
||||
if self._is_telegram_bot(bot):
|
||||
await self._start_telegram_bot_polling(name, bot)
|
||||
elif type(bot).__name__ == "WhatsAppBot":
|
||||
# WhatsApp runs its own aiohttp webhook server
|
||||
elif type(bot).__name__ in ("WhatsAppBot", "LinearBot"):
|
||||
# WhatsApp/Linear run their own aiohttp webhook servers
|
||||
self._inject_routing_handler(name, bot)
|
||||
await bot.start()
|
||||
else:
|
||||
|
||||
@@ -339,26 +339,8 @@ def get_available_integrations() -> Dict[str, bool]:
|
||||
# Import here to avoid circular imports
|
||||
try:
|
||||
from .registry import get_registry
|
||||
import asyncio
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
# Handle async call in sync context
|
||||
try:
|
||||
# Try to get existing event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# We're in an async context, use create_task
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, registry.get_available())
|
||||
return future.result()
|
||||
else:
|
||||
# No running loop, safe to use asyncio.run
|
||||
return asyncio.run(registry.get_available())
|
||||
except RuntimeError:
|
||||
# No event loop, safe to use asyncio.run
|
||||
return asyncio.run(registry.get_available())
|
||||
from .._async_bridge import run_sync
|
||||
return run_sync(get_registry().get_available())
|
||||
|
||||
except ImportError:
|
||||
# Fallback to original implementation
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
LLM endpoint environment variable resolver.
|
||||
|
||||
Provides a single source of truth for resolving LLM endpoint configuration
|
||||
from environment variables, ensuring consistent precedence across all components.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMEndpoint:
|
||||
"""LLM endpoint configuration resolved from environment variables."""
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: Optional[str]
|
||||
|
||||
|
||||
# Documented, single precedence list. Add new providers here only.
|
||||
_MODEL_VARS = ("MODEL_NAME", "OPENAI_MODEL_NAME")
|
||||
_BASE_URL_VARS = ("OPENAI_BASE_URL", "OPENAI_API_BASE", "OLLAMA_API_BASE")
|
||||
_DEFAULT_MODEL = "gpt-4o-mini"
|
||||
_DEFAULT_BASE = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def _first_set(*names: str) -> Optional[str]:
|
||||
"""Return the first environment variable that is set and non-empty."""
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def resolve_llm_endpoint(*, default_base: str = _DEFAULT_BASE) -> LLMEndpoint:
|
||||
"""
|
||||
Resolve LLM endpoint configuration from environment variables.
|
||||
|
||||
Precedence order:
|
||||
- Model: MODEL_NAME > OPENAI_MODEL_NAME > default
|
||||
- Base URL: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE > default
|
||||
- API Key: OPENAI_API_KEY
|
||||
|
||||
Args:
|
||||
default_base: Default base URL if none found in environment variables
|
||||
|
||||
Returns:
|
||||
LLMEndpoint with resolved configuration
|
||||
"""
|
||||
return LLMEndpoint(
|
||||
model=_first_set(*_MODEL_VARS) or _DEFAULT_MODEL,
|
||||
base_url=_first_set(*_BASE_URL_VARS) or default_base,
|
||||
api_key=os.environ.get("OPENAI_API_KEY"),
|
||||
)
|
||||
@@ -21,6 +21,7 @@ Example:
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
import threading
|
||||
|
||||
|
||||
# Type aliases
|
||||
@@ -35,12 +36,33 @@ class LLMProviderRegistry:
|
||||
|
||||
Manages registration and resolution of LLM providers by name.
|
||||
Supports lazy loading, aliases, and isolated instances.
|
||||
Thread-safe for concurrent operations.
|
||||
"""
|
||||
|
||||
_instance: Optional["LLMProviderRegistry"] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty registry."""
|
||||
self._providers: Dict[str, ProviderType] = {}
|
||||
self._aliases: Dict[str, str] = {} # alias -> canonical name
|
||||
self._lock = threading.RLock() # RLock for re-entrant calls
|
||||
# Register built-in providers during initialization
|
||||
_register_builtin_providers(self)
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "LLMProviderRegistry":
|
||||
"""
|
||||
Get the singleton registry instance.
|
||||
|
||||
Returns:
|
||||
LLMProviderRegistry: The singleton registry instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def register(
|
||||
self,
|
||||
@@ -62,35 +84,48 @@ class LLMProviderRegistry:
|
||||
Raises:
|
||||
ValueError: If name is already registered (unless override=True)
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check for existing registration
|
||||
if normalized_name in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
self._providers[normalized_name] = provider
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
# Check collision with existing provider name
|
||||
if normalized_alias in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' conflicts with existing provider name. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
# Check collision with existing alias
|
||||
if normalized_alias in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_alias]
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
self._aliases[normalized_alias] = normalized_name
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check for existing registration
|
||||
if normalized_name in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Provider '{name}' is already registered. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
|
||||
# Check if this canonical name conflicts with existing alias
|
||||
if normalized_name in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_name]
|
||||
raise ValueError(
|
||||
f"Cannot register provider '{name}' - it conflicts with existing alias "
|
||||
f"(currently points to '{existing_target}'). Use override=True to replace it."
|
||||
)
|
||||
|
||||
# If override=True, clean up any existing alias before registering canonical name
|
||||
if override and normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
|
||||
self._providers[normalized_name] = provider
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
normalized_alias = alias.lower()
|
||||
# Check collision with existing provider name
|
||||
if normalized_alias in self._providers and not override:
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' conflicts with existing provider name. "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
# Check collision with existing alias
|
||||
if normalized_alias in self._aliases and not override:
|
||||
existing_target = self._aliases[normalized_alias]
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
|
||||
f"Use override=True to replace it."
|
||||
)
|
||||
self._aliases[normalized_alias] = normalized_name
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -102,27 +137,28 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
True if provider was unregistered, False if not found
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._providers:
|
||||
# Remove all aliases pointing to this provider
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
del self._providers[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
# Check if it's an alias
|
||||
if normalized_name in self._aliases:
|
||||
del self._aliases[normalized_name]
|
||||
return True
|
||||
|
||||
# Check if it's a canonical name
|
||||
if normalized_name in self._providers:
|
||||
# Remove all aliases pointing to this provider
|
||||
aliases_to_remove = [
|
||||
alias for alias, canonical in self._aliases.items()
|
||||
if canonical == normalized_name
|
||||
]
|
||||
for alias in aliases_to_remove:
|
||||
del self._aliases[alias]
|
||||
|
||||
del self._providers[normalized_name]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""
|
||||
@@ -134,8 +170,9 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
True if provider is registered
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
return normalized_name in self._providers or normalized_name in self._aliases
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
return normalized_name in self._providers or normalized_name in self._aliases
|
||||
|
||||
def list(self) -> List[str]:
|
||||
"""
|
||||
@@ -144,7 +181,8 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
List of provider names
|
||||
"""
|
||||
return list(self._providers.keys())
|
||||
with self._lock:
|
||||
return list(self._providers.keys())
|
||||
|
||||
def list_all(self) -> List[str]:
|
||||
"""
|
||||
@@ -153,7 +191,8 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
List of all registered names and aliases
|
||||
"""
|
||||
return list(self._providers.keys()) + list(self._aliases.keys())
|
||||
with self._lock:
|
||||
return list(self._providers.keys()) + list(self._aliases.keys())
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
@@ -175,21 +214,22 @@ class LLMProviderRegistry:
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
|
||||
provider = self._providers.get(canonical_name)
|
||||
if provider is None:
|
||||
available = list(self._providers.keys()) # Don't call self.list() to avoid double-locking
|
||||
raise ValueError(
|
||||
f"Unknown provider: '{name}'. "
|
||||
f"Available providers: {', '.join(available) if available else 'none'}. "
|
||||
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
|
||||
)
|
||||
|
||||
# Resolve alias to canonical name
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
|
||||
provider = self._providers.get(canonical_name)
|
||||
if provider is None:
|
||||
available = self.list()
|
||||
raise ValueError(
|
||||
f"Unknown provider: '{name}'. "
|
||||
f"Available providers: {', '.join(available) if available else 'none'}. "
|
||||
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
|
||||
)
|
||||
|
||||
# Create instance
|
||||
# Create instance outside the lock
|
||||
return provider(model_id, config)
|
||||
|
||||
def get(self, name: str) -> Optional[ProviderType]:
|
||||
@@ -202,30 +242,24 @@ class LLMProviderRegistry:
|
||||
Returns:
|
||||
Provider class/factory or None
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
return self._providers.get(canonical_name)
|
||||
with self._lock:
|
||||
normalized_name = name.lower()
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
return self._providers.get(canonical_name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Default Registry Singleton
|
||||
# ============================================================================
|
||||
|
||||
_default_registry: Optional[LLMProviderRegistry] = None
|
||||
|
||||
|
||||
def get_default_llm_registry() -> LLMProviderRegistry:
|
||||
"""
|
||||
Get the default global LLM provider registry.
|
||||
|
||||
This is the registry used by create_llm_provider() when no custom registry
|
||||
is specified.
|
||||
is specified. Uses the thread-safe singleton pattern.
|
||||
"""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = LLMProviderRegistry()
|
||||
_register_builtin_providers(_default_registry)
|
||||
return _default_registry
|
||||
return LLMProviderRegistry.get_instance()
|
||||
|
||||
|
||||
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
@@ -234,10 +268,69 @@ def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
|
||||
|
||||
Uses lazy loading to avoid importing heavy dependencies at module load time.
|
||||
"""
|
||||
# Note: In Python, the actual LLM providers are in praisonaiagents.llm
|
||||
# which uses LiteLLM. This registry is for custom provider extensions.
|
||||
# Built-in providers are handled by LiteLLM automatically.
|
||||
pass
|
||||
# Built-in adapter that wraps LiteLLM so create_llm_provider works out of the box.
|
||||
class _LiteLLMProvider:
|
||||
"""Generic LiteLLM-backed provider used for openai/anthropic/google/etc."""
|
||||
def __init__(self, model_id: str, config: Optional[Dict[str, Any]] = None):
|
||||
self.provider_id = "litellm"
|
||||
self.model_id = model_id
|
||||
self.config = config or {}
|
||||
|
||||
def _resolve_model_and_kwargs(self, prompt: str, **kwargs):
|
||||
"""Helper to resolve model and kwargs for both sync and async methods."""
|
||||
try:
|
||||
import litellm # lazy
|
||||
except ImportError as err:
|
||||
raise ImportError(
|
||||
"LiteLLM is required for built-in providers. "
|
||||
"Install with: pip install litellm"
|
||||
) from err
|
||||
|
||||
provider_prefix = self.config.get("provider", "")
|
||||
full_model = f"{provider_prefix}/{self.model_id}".strip("/") if provider_prefix else self.model_id
|
||||
completion_kwargs = {
|
||||
k: v for k, v in {**self.config, **kwargs}.items() if k != "provider"
|
||||
}
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
return litellm, full_model, messages, completion_kwargs
|
||||
|
||||
def generate(self, prompt: str, **kwargs):
|
||||
"""Sync variant — uses litellm.completion()."""
|
||||
litellm, full_model, messages, completion_kwargs = self._resolve_model_and_kwargs(prompt, **kwargs)
|
||||
return litellm.completion(
|
||||
model=full_model,
|
||||
messages=messages,
|
||||
**completion_kwargs,
|
||||
)
|
||||
|
||||
async def generate_async(self, prompt: str, **kwargs):
|
||||
"""Async variant — uses litellm.acompletion() to avoid blocking the event loop.
|
||||
|
||||
generate() calls litellm.completion() which is a blocking network call.
|
||||
Calling it from an async context would stall the entire event loop.
|
||||
generate_async() uses litellm.acompletion() — the native async variant.
|
||||
"""
|
||||
litellm, full_model, messages, completion_kwargs = self._resolve_model_and_kwargs(prompt, **kwargs)
|
||||
return await litellm.acompletion(
|
||||
model=full_model,
|
||||
messages=messages,
|
||||
**completion_kwargs,
|
||||
)
|
||||
|
||||
def _make_litellm_factory(provider_prefix: str):
|
||||
def factory(model_id, config=None):
|
||||
cfg = dict(config or {})
|
||||
cfg.setdefault("provider", provider_prefix)
|
||||
return _LiteLLMProvider(model_id, cfg)
|
||||
return factory
|
||||
|
||||
# Cover the providers parse_model_string() already special-cases.
|
||||
for name, aliases in [
|
||||
("openai", ("oai",)),
|
||||
("anthropic", ("claude",)),
|
||||
("google", ("gemini", "google_genai")),
|
||||
]:
|
||||
registry.register(name, _make_litellm_factory(name), aliases=list(aliases))
|
||||
|
||||
|
||||
def register_llm_provider(
|
||||
@@ -378,5 +471,5 @@ def create_llm_provider(
|
||||
|
||||
def _reset_default_registry() -> None:
|
||||
"""Reset the default registry (mainly for testing)."""
|
||||
global _default_registry
|
||||
_default_registry = None
|
||||
with LLMProviderRegistry._instance_lock:
|
||||
LLMProviderRegistry._instance = None
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user