pushary-crewai 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pushary_crewai-0.1.0/.gitignore +86 -0
- pushary_crewai-0.1.0/LICENSE +21 -0
- pushary_crewai-0.1.0/PKG-INFO +95 -0
- pushary_crewai-0.1.0/README.md +69 -0
- pushary_crewai-0.1.0/pyproject.toml +51 -0
- pushary_crewai-0.1.0/src/pushary_crewai/__init__.py +180 -0
- pushary_crewai-0.1.0/src/pushary_crewai/py.typed +0 -0
- pushary_crewai-0.1.0/tests/test_crewai.py +108 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
node_modules/
|
|
5
|
+
.pnp
|
|
6
|
+
.pnp.js
|
|
7
|
+
|
|
8
|
+
# testing
|
|
9
|
+
/coverage
|
|
10
|
+
|
|
11
|
+
# next.js
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# python
|
|
18
|
+
__pycache__/
|
|
19
|
+
*.pyc
|
|
20
|
+
*.egg-info/
|
|
21
|
+
|
|
22
|
+
# production
|
|
23
|
+
/build
|
|
24
|
+
|
|
25
|
+
# misc
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.pem
|
|
28
|
+
|
|
29
|
+
# debug
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
yarn-debug.log*
|
|
32
|
+
yarn-error.log*
|
|
33
|
+
|
|
34
|
+
# env
|
|
35
|
+
.env
|
|
36
|
+
.env.local
|
|
37
|
+
.env.development.local
|
|
38
|
+
.env.test.local
|
|
39
|
+
.env.production.local
|
|
40
|
+
|
|
41
|
+
# vercel
|
|
42
|
+
.vercel
|
|
43
|
+
|
|
44
|
+
# local Cursor MCP config (holds the Pushary API key)
|
|
45
|
+
.cursor/mcp.json
|
|
46
|
+
|
|
47
|
+
# generated at build time: Cursor plugin bundled into the agent-hooks package
|
|
48
|
+
packages/agent-hooks/data/cursor-plugin/
|
|
49
|
+
|
|
50
|
+
# typescript
|
|
51
|
+
*.tsbuild info
|
|
52
|
+
|
|
53
|
+
# turbo
|
|
54
|
+
.turbo
|
|
55
|
+
|
|
56
|
+
# Old Shopify extension directory (deprecated)
|
|
57
|
+
shopify-theme-extension/
|
|
58
|
+
*.egg-info/
|
|
59
|
+
|
|
60
|
+
# Prospect/outreach data
|
|
61
|
+
prospect_list.csv
|
|
62
|
+
prospect_research_report.txt
|
|
63
|
+
|
|
64
|
+
# Launch planning docs
|
|
65
|
+
PRODUCTHUNT_LAUNCH_PLAN.md
|
|
66
|
+
AI-CODING-REMODEL-PLAN.md
|
|
67
|
+
|
|
68
|
+
# HyperFrames launch video (separate project)
|
|
69
|
+
video-launch/
|
|
70
|
+
|
|
71
|
+
# Remotion render outputs
|
|
72
|
+
video/public/logos/out/
|
|
73
|
+
.gstack/
|
|
74
|
+
|
|
75
|
+
# local AI-agent tooling (installed skills + plugin config, machine-local)
|
|
76
|
+
.agents/
|
|
77
|
+
.claude/
|
|
78
|
+
.cursor/rules/
|
|
79
|
+
pushary-app/.agents/
|
|
80
|
+
pushary-app/skills-lock.json
|
|
81
|
+
|
|
82
|
+
# stray empty Expo stub at repo root (real config is pushary-app/app.config.ts)
|
|
83
|
+
/app.json
|
|
84
|
+
|
|
85
|
+
# Play Store listing assets (large, iterating screenshots - keep local)
|
|
86
|
+
pushary-app/play-assets/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pushary
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pushary-crewai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Human-in-the-loop for CrewAI: a BaseTool that asks a real human on their phone and blocks on a fail-closed answer, instead of the console human_input prompt.
|
|
5
|
+
Project-URL: Homepage, https://pushary.com
|
|
6
|
+
Project-URL: Documentation, https://pushary.com/docs/agents/adapters
|
|
7
|
+
Author-email: Pushary <business@pushary.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,approvals,basetool,crewai,human-in-the-loop
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: crewai<2,>=1.0
|
|
24
|
+
Requires-Dist: pushary>=1.3.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pushary-crewai
|
|
28
|
+
|
|
29
|
+
Human-in-the-loop for [CrewAI](https://www.crewai.com). Replace the console
|
|
30
|
+
`human_input=True` prompt with a tool that reaches a real person on their phone and
|
|
31
|
+
blocks until they answer, fail-closed.
|
|
32
|
+
|
|
33
|
+
Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install pushary-crewai
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
|
|
42
|
+
|
|
43
|
+
## Connect a phone once
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from pushary_crewai import connect
|
|
47
|
+
|
|
48
|
+
link = connect("user_123") # show this to your end-user; one tap connects their phone
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Give an agent an ask-human tool
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from crewai import Agent, Task, Crew
|
|
55
|
+
from pushary_crewai import make_ask_human_tool
|
|
56
|
+
|
|
57
|
+
agent = Agent(
|
|
58
|
+
role="Ops",
|
|
59
|
+
goal="Ship safely",
|
|
60
|
+
backstory="Careful operator.",
|
|
61
|
+
tools=[make_ask_human_tool("user_123")],
|
|
62
|
+
)
|
|
63
|
+
task = Task(
|
|
64
|
+
description="Draft the release. Before finalizing, call ask_human to get approval.",
|
|
65
|
+
expected_output="approved release notes",
|
|
66
|
+
agent=agent,
|
|
67
|
+
)
|
|
68
|
+
Crew(agents=[agent], tasks=[task]).kickoff()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The tool blocks until the person answers and returns a fail-closed instruction. The
|
|
72
|
+
`external_id` is bound when you build the tool, never taken from the model, so a
|
|
73
|
+
prompt-injected agent cannot ask the wrong person.
|
|
74
|
+
|
|
75
|
+
## Lower-level helpers
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from pushary_crewai import ask_human
|
|
79
|
+
|
|
80
|
+
d = ask_human("Approve this refund?", external_id="user_123", type="confirm")
|
|
81
|
+
if d["approved"]:
|
|
82
|
+
issue_refund()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
- `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone.
|
|
88
|
+
- `make_ask_human_tool(external_id, *, name=..., ...)` — a CrewAI `BaseTool` bound to that user.
|
|
89
|
+
- `ask_human(question, *, external_id, type="confirm", ...)` — blocking, returns the decision dict.
|
|
90
|
+
- `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback for a durable path.
|
|
91
|
+
- `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# pushary-crewai
|
|
2
|
+
|
|
3
|
+
Human-in-the-loop for [CrewAI](https://www.crewai.com). Replace the console
|
|
4
|
+
`human_input=True` prompt with a tool that reaches a real person on their phone and
|
|
5
|
+
blocks until they answer, fail-closed.
|
|
6
|
+
|
|
7
|
+
Requires the Pushary [Partner plan](https://pushary.com/agent-notifications-integration).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pushary-crewai
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Set `PUSHARY_API_KEY` (get it in your [dashboard](https://pushary.com/dashboard/settings)).
|
|
16
|
+
|
|
17
|
+
## Connect a phone once
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from pushary_crewai import connect
|
|
21
|
+
|
|
22
|
+
link = connect("user_123") # show this to your end-user; one tap connects their phone
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Give an agent an ask-human tool
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from crewai import Agent, Task, Crew
|
|
29
|
+
from pushary_crewai import make_ask_human_tool
|
|
30
|
+
|
|
31
|
+
agent = Agent(
|
|
32
|
+
role="Ops",
|
|
33
|
+
goal="Ship safely",
|
|
34
|
+
backstory="Careful operator.",
|
|
35
|
+
tools=[make_ask_human_tool("user_123")],
|
|
36
|
+
)
|
|
37
|
+
task = Task(
|
|
38
|
+
description="Draft the release. Before finalizing, call ask_human to get approval.",
|
|
39
|
+
expected_output="approved release notes",
|
|
40
|
+
agent=agent,
|
|
41
|
+
)
|
|
42
|
+
Crew(agents=[agent], tasks=[task]).kickoff()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The tool blocks until the person answers and returns a fail-closed instruction. The
|
|
46
|
+
`external_id` is bound when you build the tool, never taken from the model, so a
|
|
47
|
+
prompt-injected agent cannot ask the wrong person.
|
|
48
|
+
|
|
49
|
+
## Lower-level helpers
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from pushary_crewai import ask_human
|
|
53
|
+
|
|
54
|
+
d = ask_human("Approve this refund?", external_id="user_123", type="confirm")
|
|
55
|
+
if d["approved"]:
|
|
56
|
+
issue_refund()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
- `connect(external_id, *, api_key=None, base_url=None)` — enroll an end-user's phone.
|
|
62
|
+
- `make_ask_human_tool(external_id, *, name=..., ...)` — a CrewAI `BaseTool` bound to that user.
|
|
63
|
+
- `ask_human(question, *, external_id, type="confirm", ...)` — blocking, returns the decision dict.
|
|
64
|
+
- `resolve_pushary_callback(raw_body, signature, secret)` — verify + parse a callback for a durable path.
|
|
65
|
+
- `describe_answer(type, result)`, `is_affirmative(answer)`, `deterministic_key(parts)`, `SIGNATURE_HEADER`.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pushary-crewai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Human-in-the-loop for CrewAI: a BaseTool that asks a real human on their phone and blocks on a fail-closed answer, instead of the console human_input prompt."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Pushary", email = "business@pushary.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"crewai",
|
|
15
|
+
"human-in-the-loop",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"approvals",
|
|
18
|
+
"basetool",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pushary>=1.3.2",
|
|
35
|
+
"crewai>=1.0,<2",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://pushary.com"
|
|
40
|
+
Documentation = "https://pushary.com/docs/agents/adapters"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/pushary_crewai"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
include = [
|
|
47
|
+
"src/pushary_crewai",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"tests",
|
|
51
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Human-in-the-loop for CrewAI, powered by Pushary.
|
|
2
|
+
|
|
3
|
+
Replace CrewAI's console ``human_input=True`` prompt with a tool that reaches a real
|
|
4
|
+
person on their phone. ``make_ask_human_tool`` returns a ``BaseTool`` you hand to an
|
|
5
|
+
``Agent``; it blocks until the person answers and fails closed.
|
|
6
|
+
|
|
7
|
+
Zero framework import at module load: CrewAI is imported lazily inside the tool
|
|
8
|
+
factory, so the core helpers work (and test) without it installed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from pushary import (
|
|
17
|
+
PusharyServer,
|
|
18
|
+
SIGNATURE_HEADER,
|
|
19
|
+
deterministic_key,
|
|
20
|
+
is_approved,
|
|
21
|
+
parse_decision_callback,
|
|
22
|
+
verify_webhook_signature,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"connect",
|
|
29
|
+
"ask_human",
|
|
30
|
+
"make_ask_human_tool",
|
|
31
|
+
"describe_answer",
|
|
32
|
+
"resolve_pushary_callback",
|
|
33
|
+
"is_affirmative",
|
|
34
|
+
"deterministic_key",
|
|
35
|
+
"SIGNATURE_HEADER",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_DEFAULT_DESCRIPTION = "Ask a real human to approve, choose, or answer. Blocks until they reply on their phone."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _client(api_key: Optional[str] = None, base_url: Optional[str] = None) -> PusharyServer:
|
|
43
|
+
key = api_key or os.environ.get("PUSHARY_API_KEY")
|
|
44
|
+
if not key:
|
|
45
|
+
raise ValueError("Pushary: set PUSHARY_API_KEY or pass api_key=... to the CrewAI helpers.")
|
|
46
|
+
return PusharyServer(api_key=key, base_url=base_url)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _idempotency_key(external_id: str, node: str, question: str) -> str:
|
|
50
|
+
return deterministic_key([external_id, node, question])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_affirmative(answer: Optional[str]) -> bool:
|
|
54
|
+
"""Fail-closed yes/no check for a confirm answer."""
|
|
55
|
+
return is_approved("answered", "confirm", answer)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def connect(external_id: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> str:
|
|
59
|
+
"""Connect one end-user's phone (keyless). Returns a single-use link to show them."""
|
|
60
|
+
return _client(api_key, base_url).enroll(external_id)["universalLink"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ask_human(
|
|
64
|
+
question: str,
|
|
65
|
+
*,
|
|
66
|
+
external_id: str,
|
|
67
|
+
type: str = "confirm",
|
|
68
|
+
options: Optional[List[str]] = None,
|
|
69
|
+
node: str = "ask-human",
|
|
70
|
+
context: Optional[str] = None,
|
|
71
|
+
agent_name: Optional[str] = None,
|
|
72
|
+
timeout_seconds: Optional[float] = None,
|
|
73
|
+
api_key: Optional[str] = None,
|
|
74
|
+
base_url: Optional[str] = None,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
"""Blocking ask: create a decision and poll durably until answered or the deadline.
|
|
77
|
+
|
|
78
|
+
Returns the decision dict with a fail-closed ``approved`` flag. Idempotency is
|
|
79
|
+
keyed by external_id + node + question.
|
|
80
|
+
"""
|
|
81
|
+
return _client(api_key, base_url).decisions.ask(
|
|
82
|
+
question,
|
|
83
|
+
type=type,
|
|
84
|
+
options=options,
|
|
85
|
+
external_id=external_id,
|
|
86
|
+
context=context,
|
|
87
|
+
agent_name=agent_name,
|
|
88
|
+
timeout_seconds=timeout_seconds,
|
|
89
|
+
idempotency_key=_idempotency_key(external_id, node, question),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def describe_answer(type: str, result: Dict[str, Any]) -> str:
|
|
94
|
+
"""Turn a decision outcome into an unambiguous instruction for the agent."""
|
|
95
|
+
if not result.get("answered"):
|
|
96
|
+
return (
|
|
97
|
+
f"No answer (status: {result.get('status')}). "
|
|
98
|
+
"Treat this as NOT approved and do not proceed."
|
|
99
|
+
)
|
|
100
|
+
if type == "confirm":
|
|
101
|
+
return (
|
|
102
|
+
"The human approved. You may proceed."
|
|
103
|
+
if result.get("approved")
|
|
104
|
+
else "The human declined. Do not proceed."
|
|
105
|
+
)
|
|
106
|
+
return f"The human answered: {result.get('value') or ''}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_pushary_callback(
|
|
110
|
+
raw_body: Any, signature: Optional[str], secret: str
|
|
111
|
+
) -> Optional[Dict[str, Any]]:
|
|
112
|
+
"""Verify a callback signature and parse it, or return None."""
|
|
113
|
+
if not verify_webhook_signature(raw_body, signature, secret):
|
|
114
|
+
return None
|
|
115
|
+
cb = parse_decision_callback(raw_body)
|
|
116
|
+
if not cb:
|
|
117
|
+
return None
|
|
118
|
+
return {
|
|
119
|
+
"correlationId": cb.get("correlationId"),
|
|
120
|
+
"answer": cb.get("answer"),
|
|
121
|
+
"value": cb.get("value"),
|
|
122
|
+
"approved": is_affirmative(cb.get("answer")),
|
|
123
|
+
"context": cb.get("context"),
|
|
124
|
+
"answeredAt": cb.get("answeredAt"),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_ask_human_tool(
|
|
129
|
+
external_id: str,
|
|
130
|
+
*,
|
|
131
|
+
api_key: Optional[str] = None,
|
|
132
|
+
base_url: Optional[str] = None,
|
|
133
|
+
agent_name: Optional[str] = None,
|
|
134
|
+
name: str = "ask_human",
|
|
135
|
+
description: Optional[str] = None,
|
|
136
|
+
node: str = "ask-human",
|
|
137
|
+
):
|
|
138
|
+
"""Return a CrewAI ``BaseTool`` that asks ``external_id`` and blocks on their answer.
|
|
139
|
+
|
|
140
|
+
``external_id`` is bound here, never taken from the model, so a prompt-injected
|
|
141
|
+
agent cannot redirect an approval to another user. Give the returned tool to an
|
|
142
|
+
``Agent(tools=[...])`` and drop ``human_input=True``.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
agent = Agent(role="Ops", goal="Ship safely", tools=[make_ask_human_tool("user_123")])
|
|
146
|
+
```
|
|
147
|
+
"""
|
|
148
|
+
# Lazy import so the module loads (and tests) without CrewAI installed.
|
|
149
|
+
from crewai.tools import BaseTool
|
|
150
|
+
from pydantic import BaseModel, Field
|
|
151
|
+
from typing import Type
|
|
152
|
+
|
|
153
|
+
tool_name = name or "ask_human"
|
|
154
|
+
tool_description = description or _DEFAULT_DESCRIPTION
|
|
155
|
+
|
|
156
|
+
class _AskHumanInput(BaseModel):
|
|
157
|
+
question: str = Field(..., description="The exact question to put to the human.")
|
|
158
|
+
type: str = Field(
|
|
159
|
+
"confirm",
|
|
160
|
+
description="confirm = yes/no, select = pick an option, input = free text.",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
class AskHumanTool(BaseTool):
|
|
164
|
+
name: str = tool_name
|
|
165
|
+
description: str = tool_description
|
|
166
|
+
args_schema: Type[BaseModel] = _AskHumanInput
|
|
167
|
+
|
|
168
|
+
def _run(self, question: str, type: str = "confirm", **_: Any) -> str:
|
|
169
|
+
result = ask_human(
|
|
170
|
+
question,
|
|
171
|
+
external_id=external_id,
|
|
172
|
+
type=type,
|
|
173
|
+
node=node,
|
|
174
|
+
agent_name=agent_name,
|
|
175
|
+
api_key=api_key,
|
|
176
|
+
base_url=base_url,
|
|
177
|
+
)
|
|
178
|
+
return describe_answer(type, result)
|
|
179
|
+
|
|
180
|
+
return AskHumanTool()
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tests for pushary_crewai. Framework-free: CrewAI is never imported here (the tool
|
|
2
|
+
factory imports it lazily), so the core helpers are exercised without it installed.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
import pushary_crewai as pc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeDecisions:
|
|
14
|
+
def __init__(self, ask_result=None):
|
|
15
|
+
self.ask_calls = []
|
|
16
|
+
self._ask_result = ask_result or {}
|
|
17
|
+
|
|
18
|
+
def ask(self, question, **kwargs):
|
|
19
|
+
self.ask_calls.append({"question": question, **kwargs})
|
|
20
|
+
return self._ask_result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeClient:
|
|
24
|
+
def __init__(self, decisions=None, enroll_result=None):
|
|
25
|
+
self.decisions = decisions or FakeDecisions()
|
|
26
|
+
self._enroll_result = enroll_result or {}
|
|
27
|
+
self.enroll_calls = []
|
|
28
|
+
|
|
29
|
+
def enroll(self, external_id):
|
|
30
|
+
self.enroll_calls.append(external_id)
|
|
31
|
+
return self._enroll_result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WithFakeClient:
|
|
35
|
+
def __init__(self, client):
|
|
36
|
+
self.client = client
|
|
37
|
+
self._orig = None
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
self._orig = pc._client
|
|
41
|
+
pc._client = lambda *a, **k: self.client
|
|
42
|
+
return self.client
|
|
43
|
+
|
|
44
|
+
def __exit__(self, *exc):
|
|
45
|
+
pc._client = self._orig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SECRET = "whsec_test"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sign(body: str) -> str:
|
|
52
|
+
return hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConnectTests(unittest.TestCase):
|
|
56
|
+
def test_connect_returns_universal_link(self):
|
|
57
|
+
client = FakeClient(enroll_result={"universalLink": "https://pushary.com/e/tok"})
|
|
58
|
+
with WithFakeClient(client):
|
|
59
|
+
self.assertEqual(pc.connect("user_1"), "https://pushary.com/e/tok")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AskHumanTests(unittest.TestCase):
|
|
63
|
+
def test_ask_human_forwards_deterministic_key_and_returns_dict(self):
|
|
64
|
+
decisions = FakeDecisions(ask_result={"answered": True, "value": "yes", "approved": True})
|
|
65
|
+
with WithFakeClient(FakeClient(decisions=decisions)):
|
|
66
|
+
out = pc.ask_human("Approve?", external_id="user_1", node="gate")
|
|
67
|
+
self.assertTrue(out["approved"])
|
|
68
|
+
self.assertEqual(
|
|
69
|
+
decisions.ask_calls[0]["idempotency_key"],
|
|
70
|
+
pc.deterministic_key(["user_1", "gate", "Approve?"]),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MakeToolTests(unittest.TestCase):
|
|
75
|
+
def test_factory_lazily_imports_crewai(self):
|
|
76
|
+
# CrewAI is not installed in this env, so building the tool raises ImportError
|
|
77
|
+
# from the lazy import (proving the module itself loads without CrewAI).
|
|
78
|
+
with self.assertRaises(ImportError):
|
|
79
|
+
pc.make_ask_human_tool("user_1")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DescribeAnswerTests(unittest.TestCase):
|
|
83
|
+
def test_formats_every_outcome(self):
|
|
84
|
+
self.assertIn("approved", pc.describe_answer("confirm", {"answered": True, "approved": True}))
|
|
85
|
+
self.assertIn("declined", pc.describe_answer("confirm", {"answered": True, "approved": False}))
|
|
86
|
+
self.assertIn("NOT approved", pc.describe_answer("confirm", {"answered": False, "status": "expired"}))
|
|
87
|
+
self.assertIn("B", pc.describe_answer("select", {"answered": True, "value": "B"}))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResolveCallbackTests(unittest.TestCase):
|
|
91
|
+
def test_verifies_and_folds_approved(self):
|
|
92
|
+
body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": ""})
|
|
93
|
+
self.assertTrue(pc.resolve_pushary_callback(body, sign(body), SECRET)["approved"])
|
|
94
|
+
|
|
95
|
+
def test_rejects_bad_signature(self):
|
|
96
|
+
body = json.dumps({"correlationId": "d1", "answer": "yes", "answeredAt": ""})
|
|
97
|
+
self.assertIsNone(pc.resolve_pushary_callback(body, "nope", SECRET))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class IsAffirmativeTests(unittest.TestCase):
|
|
101
|
+
def test_fail_closed(self):
|
|
102
|
+
self.assertTrue(pc.is_affirmative("yes"))
|
|
103
|
+
self.assertFalse(pc.is_affirmative("no"))
|
|
104
|
+
self.assertFalse(pc.is_affirmative(None))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|