mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Merge branch 'humanlayer:main' into main
This commit is contained in:
37
.claude/commands/create_worktree.md
Normal file
37
.claude/commands/create_worktree.md
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
2. set up worktree for implementation:
|
||||
2a. read `hack/create_worktree.sh` and create a new worktree with the Linear branch name: `./hack/create_worktree.sh ENG-XXXX BRANCH_NAME`
|
||||
|
||||
3. determine required data:
|
||||
|
||||
branch name
|
||||
path to plan file (use relative path only)
|
||||
launch prompt
|
||||
command to run
|
||||
|
||||
**IMPORTANT PATH USAGE:**
|
||||
- The thoughts/ directory is synced between the main repo and worktrees
|
||||
- Always use ONLY the relative path starting with `thoughts/shared/...` without any directory prefix
|
||||
- Example: `thoughts/shared/plans/fix-mcp-keepalive-proper.md` (not the full absolute path)
|
||||
- This works because thoughts are synced and accessible from the worktree
|
||||
|
||||
3a. confirm with the user by sending a message to the Human
|
||||
|
||||
```
|
||||
based on the input, I plan to create a worktree with the following details:
|
||||
|
||||
worktree path: ~/wt/humanlayer/ENG-XXXX
|
||||
branch name: BRANCH_NAME
|
||||
path to plan file: $FILEPATH
|
||||
launch prompt:
|
||||
|
||||
/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link
|
||||
|
||||
command to run:
|
||||
|
||||
humanlayer launch --model opus -w ~/wt/humanlayer/ENG-XXXX "/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link"
|
||||
```
|
||||
|
||||
incorporate any user feedback then:
|
||||
|
||||
4. launch implementation session: `humanlayer launch --model opus -w ~/wt/humanlayer/ENG-XXXX "/implement_plan at $FILEPATH and when you are done implementing and all tests pass, read ./claude/commands/commit.md and create a commit, then read ./claude/commands/describe_pr.md and create a PR, then add a comment to the Linear ticket with the PR link"`
|
||||
@@ -17,6 +17,14 @@ make codelayer-dev
|
||||
|
||||
When the Web UI launches in dev mode, you'll need to launch a managed daemon with it - click the 🐞 icon in the bottom right and launch a managed daemon.
|
||||
|
||||
## Commands cheat sheet
|
||||
|
||||
1. `/research_codebase`
|
||||
2. `/create_plan`
|
||||
3. `/implement_plan`
|
||||
4. `/commit`
|
||||
5. `gh pr create --fill`
|
||||
6. `/describe_pr`
|
||||
|
||||
## Running Tests
|
||||
|
||||
|
||||
140
README.md
140
README.md
@@ -4,22 +4,19 @@
|
||||
|
||||
</div>
|
||||
|
||||
**HumanLayer** is an API and SDK that enables AI Agents to contact humans for help, feedback, and approvals.
|
||||
|
||||
Bring your LLM (OpenAI, Llama, Claude, etc) and Framework (LangChain, CrewAI, etc) and start giving your AI agents safe access to the world.
|
||||
🚧 **HumanLayer** is undergoing some changes...stay tuned! 🚧
|
||||
|
||||
<div align="center">
|
||||
|
||||
<h3>
|
||||
|
||||
[Homepage](https://www.humanlayer.dev/) | [Get Started](https://humanlayer.dev/docs/quickstart-python) | [Discord](https://humanlayer.dev/discord)
|
||||
[HumanLayer Code](https://humanlayer.dev/code) | [Discord](https://humanlayer.dev/discord) | [Release](https://github.com/humanlayer/humanlayer/releases)
|
||||
|
||||
|
||||
</h3>
|
||||
|
||||
[](https://github.com/humanlayer/humanlayer)
|
||||
[](https://opensource.org/licenses/Apache-2)
|
||||
[](https://pypi.org/project/humanlayer/)
|
||||
[](https://www.npmjs.com/package/humanlayer)
|
||||
|
||||
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=fcfc0926-d841-47fb-b8a6-6aba3a6c3228" />
|
||||
|
||||
@@ -35,88 +32,6 @@ Bring your LLM (OpenAI, Llama, Claude, etc) and Framework (LangChain, CrewAI, et
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Getting Started
|
||||
|
||||
To get started, check out [Getting Started](https://humanlayer.dev/docs/quickstart-python), watch the [Getting Started Video](https://www.loom.com/share/7c65d48d18d1421a864a1591ff37e2bf), or jump straight into one of the [Examples](./examples/):
|
||||
|
||||
- 🦜⛓️ [LangChain](./examples/langchain/)
|
||||
- 🚣 [CrewAI](./examples/crewai/)
|
||||
- 🦾 [ControlFlow](./examples/controlflow/)
|
||||
- 🧠 [Raw OpenAI Client](./examples/openai_client/)
|
||||
|
||||
<div align="center">
|
||||
<a target="_blank" href="https://youtu.be/5sbN8rh_S5Q"><img width="60%" alt="video thumbnail showing editor" src="./docs/images/video-preview.png"></a>
|
||||
</div>
|
||||
|
||||
## Example
|
||||
|
||||
HumanLayer supports either Python or Typescript / JS.
|
||||
|
||||
```shell
|
||||
pip install humanlayer
|
||||
```
|
||||
|
||||
```python
|
||||
from humanlayer import HumanLayer
|
||||
hl = HumanLayer()
|
||||
|
||||
@hl.require_approval()
|
||||
def send_email(to: str, subject: str, body: str):
|
||||
"""Send an email to the customer"""
|
||||
...
|
||||
|
||||
|
||||
# made up function, use whatever
|
||||
# tool-calling framework you prefer
|
||||
run_llm_task(
|
||||
prompt="""Send an email welcoming the customer to
|
||||
the platform and encouraging them to invite a team member.""",
|
||||
tools=[send_email],
|
||||
llm="gpt-4o"
|
||||
)
|
||||
```
|
||||
|
||||
<div align="center"><img style="width: 400px" alt="A screenshot of slack showing a human replying to the bot" src="https://www.humanlayer.dev/slack-conversation.png"></div>
|
||||
|
||||
For Typescript, install with npm:
|
||||
|
||||
```
|
||||
npm install @humanlayer/sdk
|
||||
```
|
||||
|
||||
More python and TS examples in the [framework specific examples](./examples) or the [Getting Started Guides](https://humanlayer.dev/docs/frameworks) to get hands on.
|
||||
|
||||
#### Human as Tool
|
||||
|
||||
You can also use `hl.human_as_tool()` to bring a human into the loop for any reason. This can be useful for debugging, asking for advice, or just getting a human's opinion on something.
|
||||
|
||||
```python
|
||||
# human_as_tool.py
|
||||
|
||||
from humanlayer import HumanLayer
|
||||
hl = HumanLayer()
|
||||
contact_a_human = hl.human_as_tool()
|
||||
|
||||
def send_email(to: str, subject: str, body: str):
|
||||
"""Send an email to the customer"""
|
||||
...
|
||||
|
||||
# made up method, use whatever
|
||||
# framework you prefer
|
||||
run_llm_task(
|
||||
prompt="""Send an email welcoming the customer to
|
||||
the platform and encouraging them to invite a team member.
|
||||
|
||||
Contact a human for collaboration and feedback on your email
|
||||
draft
|
||||
""",
|
||||
tools=[send_email, contact_a_human],
|
||||
llm="gpt-4o"
|
||||
)
|
||||
```
|
||||
|
||||
See the [examples](./examples) for more advanced human as tool examples, and workflows that combine both concepts.
|
||||
|
||||
## Why HumanLayer?
|
||||
|
||||
Functions and tools are a key part of [Agentic Workflows](https://www.deeplearning.ai/the-batch/how-agents-can-improve-llm-performance). They enable LLMs to interact meaningfully with the outside world and automate broad scopes of impactful work. Correct and accurate function calling is essential for AI agents that do meaningful things like book appointments, interact with customers, manage billing information, write+execute code, and more.
|
||||
@@ -173,48 +88,6 @@ While early versions of these agents may technically be "human initiated" in tha
|
||||
|
||||
Example use cases for these outer loop agents include [the linkedin inbox assistant](./examples/langchain/04-human_as_tool_linkedin.py) and [the customer onboarding assistant](./examples/langchain/05-approvals_and_humans_composite.py), but that's really just scratching the surface.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Require Human Approval for Function Calls**: the `@hl.require_approval()` decorator blocks specific function calls until a human has been consulted - upon denial, feedback will be passed to the LLM
|
||||
- **Human as Tool**: generic `hl.human_as_tool()` allows for contacting a human for answers, advice, or feedback
|
||||
- **OmniChannel Contact**: Contact humans and collect responses across Slack, Email, Discord, and more
|
||||
- **Granular Routing**: Route approvals to specific teams or individuals
|
||||
- **Bring your own LLM + Framework**: Because HumanLayer is implemented at tools layer, it supports any LLM and all major orchestration frameworks that support tool calling.
|
||||
|
||||
## Examples
|
||||
|
||||
You can test different real life examples of HumanLayer in the [examples folder](./examples/):
|
||||
|
||||
- 🦜⛓️ [LangChain Math](./examples/langchain/01-math_example.py)
|
||||
- 🦜⛓️ [LangChain Human As Tool](./examples/langchain/03-human_as_tool.py)
|
||||
- 🚣 [CrewAI Math](./examples/crewai/crewai_math.py)
|
||||
- 🦾 [ControlFlow Math](./examples/controlflow/controlflow_math.py)
|
||||
- 🧠 [Raw OpenAI Client](./examples/openai_client/01-math_example.py)
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Feature | Status |
|
||||
| ---------------------------------------------------------------------------------- | ------------------- |
|
||||
| Require Approval | ⚙️ Beta |
|
||||
| Human as Tool | ⚙️ Beta |
|
||||
| CLI Approvals | ⚙️ Beta |
|
||||
| CLI Human as Tool | ⚙️ Beta |
|
||||
| Slack Approvals | ⚙️ Beta |
|
||||
| Langchain Support | ⚙️ Beta |
|
||||
| CrewAI Support | ⚙️ Beta |
|
||||
| [GripTape Support](./examples/griptape) | ⚗️ Alpha |
|
||||
| [GripTape Builtin Tools Support](./examples/griptape/02-decorate-existing-tool.py) | 🗓️ Planned |
|
||||
| Controlflow Support | ⚗️ Alpha |
|
||||
| Custom Response options | ⚗️ Alpha |
|
||||
| Open Protocol for BYO server | 🗓️ Planned |
|
||||
| Composite Contact Channels | 🚧 Work in progress |
|
||||
| Async / Webhook support | 🗓️ Planned |
|
||||
| SMS/RCS Approvals | 🗓️ Planned |
|
||||
| Discord Approvals | 🗓️ Planned |
|
||||
| Email Approvals | ⚙️ Beta |
|
||||
| LlamaIndex Support | 🗓️ Planned |
|
||||
| Haystack Support | 🗓️ Planned |
|
||||
|
||||
## Contributing
|
||||
|
||||
The HumanLayer SDK and docs are open-source and we welcome contributions in the form of issues, documentation, pull requests, and more. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
|
||||
@@ -223,11 +96,6 @@ The HumanLayer SDK and docs are open-source and we welcome contributions in the
|
||||
|
||||
[](https://star-history.com/#humanlayer/humanlayer&Date)
|
||||
|
||||
Shouts out to [@erquhart](https://github.com/erquhart) for this one
|
||||
|
||||
<div align="center">
|
||||
<img width="360" src="https://github.com/user-attachments/assets/849a7149-daff-43a7-8ca9-427ccd0ae77c" />
|
||||
</div>
|
||||
|
||||
## Development Conventions
|
||||
|
||||
@@ -244,4 +112,4 @@ We use a priority-based TODO annotation system throughout the codebase:
|
||||
|
||||
## License
|
||||
|
||||
The HumanLayer SDK in this repo is licensed under the Apache 2 License.
|
||||
The HumanLayer SDK and CodeLayer sources in this repo are licensed under the Apache 2 License.
|
||||
|
||||
@@ -29,6 +29,7 @@ type CreateApprovalRequest struct {
|
||||
RunID string `json:"run_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateApprovalResponse is the response for creating a local approval
|
||||
@@ -54,10 +55,22 @@ func (h *ApprovalHandlers) HandleCreateApproval(ctx context.Context, params json
|
||||
return nil, fmt.Errorf("tool_input is required")
|
||||
}
|
||||
|
||||
// Create the approval
|
||||
approvalID, err := h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create approval: %w", err)
|
||||
// Create approval with or without tool use ID
|
||||
var approvalID string
|
||||
if req.ToolUseID != "" {
|
||||
// Use the new method that accepts tool use ID
|
||||
approval, err := h.approvals.CreateApprovalWithToolUseID(ctx, req.RunID, req.ToolName, req.ToolInput, req.ToolUseID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create approval with tool use ID: %w", err)
|
||||
}
|
||||
approvalID = approval.ID
|
||||
} else {
|
||||
// Fall back to legacy method for backward compatibility
|
||||
var err error
|
||||
approvalID, err = h.approvals.CreateApproval(ctx, req.RunID, req.ToolName, req.ToolInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create approval: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &CreateApprovalResponse{
|
||||
|
||||
@@ -344,18 +344,73 @@ func TestContinueSessionInheritance(t *testing.T) {
|
||||
t.Fatalf("Failed to get child MCP servers: %v", err)
|
||||
}
|
||||
|
||||
// Should have inherited the MCP servers
|
||||
if len(childMCPServers) != len(mcpServers) {
|
||||
t.Errorf("MCP servers not inherited: got %d, want %d", len(childMCPServers), len(mcpServers))
|
||||
// Should have inherited the MCP servers plus injected codelayer
|
||||
expectedCount := len(mcpServers) + 1 // +1 for injected codelayer
|
||||
if len(childMCPServers) != expectedCount {
|
||||
t.Errorf("MCP servers count mismatch: got %d, want %d", len(childMCPServers), expectedCount)
|
||||
}
|
||||
|
||||
// Verify server details (accounting for HUMANLAYER_RUN_ID being added)
|
||||
// Find and verify the injected codelayer server
|
||||
var foundCodelayer bool
|
||||
var codelayerIdx int
|
||||
for i, server := range childMCPServers {
|
||||
if server.Name != mcpServers[i].Name {
|
||||
t.Errorf("MCP server %d name mismatch: got %s, want %s", i, server.Name, mcpServers[i].Name)
|
||||
if server.Name == "codelayer" {
|
||||
foundCodelayer = true
|
||||
codelayerIdx = i
|
||||
|
||||
// Verify codelayer configuration
|
||||
if server.Command != "hlyr" {
|
||||
t.Errorf("Codelayer command mismatch: got %s, want hlyr", server.Command)
|
||||
}
|
||||
|
||||
var args []string
|
||||
if err := json.Unmarshal([]byte(server.ArgsJSON), &args); err != nil {
|
||||
t.Fatalf("Failed to unmarshal codelayer args: %v", err)
|
||||
}
|
||||
expectedArgs := []string{"mcp", "claude_approvals"}
|
||||
if len(args) != len(expectedArgs) {
|
||||
t.Errorf("Codelayer args length mismatch: got %d, want %d", len(args), len(expectedArgs))
|
||||
} else {
|
||||
for j, arg := range args {
|
||||
if arg != expectedArgs[j] {
|
||||
t.Errorf("Codelayer arg[%d] mismatch: got %s, want %s", j, arg, expectedArgs[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var env map[string]string
|
||||
if err := json.Unmarshal([]byte(server.EnvJSON), &env); err != nil {
|
||||
t.Fatalf("Failed to unmarshal codelayer env: %v", err)
|
||||
}
|
||||
if env["HUMANLAYER_SESSION_ID"] != childSession.ID {
|
||||
t.Errorf("Codelayer env HUMANLAYER_SESSION_ID mismatch: got %s, want %s", env["HUMANLAYER_SESSION_ID"], childSession.ID)
|
||||
}
|
||||
break
|
||||
}
|
||||
if server.Command != mcpServers[i].Command {
|
||||
t.Errorf("MCP server %d command mismatch: got %s, want %s", i, server.Command, mcpServers[i].Command)
|
||||
}
|
||||
|
||||
if !foundCodelayer {
|
||||
t.Error("Injected codelayer MCP server not found")
|
||||
}
|
||||
|
||||
// Verify inherited servers (excluding codelayer)
|
||||
parentIdx := 0
|
||||
for i, server := range childMCPServers {
|
||||
// Skip the codelayer server
|
||||
if i == codelayerIdx {
|
||||
continue
|
||||
}
|
||||
|
||||
if parentIdx >= len(mcpServers) {
|
||||
t.Errorf("Extra unexpected MCP server found: %s", server.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if server.Name != mcpServers[parentIdx].Name {
|
||||
t.Errorf("MCP server %d name mismatch: got %s, want %s", parentIdx, server.Name, mcpServers[parentIdx].Name)
|
||||
}
|
||||
if server.Command != mcpServers[parentIdx].Command {
|
||||
t.Errorf("MCP server %d command mismatch: got %s, want %s", parentIdx, server.Command, mcpServers[parentIdx].Command)
|
||||
}
|
||||
|
||||
// Compare args (deserialize to compare content)
|
||||
@@ -363,15 +418,15 @@ func TestContinueSessionInheritance(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(server.ArgsJSON), &childArgs); err != nil {
|
||||
t.Fatalf("Failed to unmarshal child args: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(mcpServers[i].ArgsJSON), &parentArgs); err != nil {
|
||||
if err := json.Unmarshal([]byte(mcpServers[parentIdx].ArgsJSON), &parentArgs); err != nil {
|
||||
t.Fatalf("Failed to unmarshal parent args: %v", err)
|
||||
}
|
||||
if len(childArgs) != len(parentArgs) {
|
||||
t.Errorf("MCP server %d args length mismatch", i)
|
||||
t.Errorf("MCP server %d args length mismatch", parentIdx)
|
||||
} else {
|
||||
for j, arg := range childArgs {
|
||||
if arg != parentArgs[j] {
|
||||
t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", i, j, arg, parentArgs[j])
|
||||
t.Errorf("MCP server %d arg[%d] mismatch: got %s, want %s", parentIdx, j, arg, parentArgs[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,21 +436,23 @@ func TestContinueSessionInheritance(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(server.EnvJSON), &childEnv); err != nil {
|
||||
t.Fatalf("Failed to unmarshal child env: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(mcpServers[i].EnvJSON), &parentEnv); err != nil {
|
||||
if err := json.Unmarshal([]byte(mcpServers[parentIdx].EnvJSON), &parentEnv); err != nil {
|
||||
t.Fatalf("Failed to unmarshal parent env: %v", err)
|
||||
}
|
||||
|
||||
// Child should have all parent env vars plus HUMANLAYER_RUN_ID
|
||||
for key, val := range parentEnv {
|
||||
if childEnv[key] != val {
|
||||
t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", i, key, childEnv[key], val)
|
||||
t.Errorf("MCP server %d env[%s] mismatch: got %s, want %s", parentIdx, key, childEnv[key], val)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have HUMANLAYER_RUN_ID added
|
||||
if _, ok := childEnv["HUMANLAYER_RUN_ID"]; !ok {
|
||||
t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", i)
|
||||
t.Errorf("MCP server %d missing HUMANLAYER_RUN_ID in env", parentIdx)
|
||||
}
|
||||
|
||||
parentIdx++
|
||||
}
|
||||
})
|
||||
|
||||
@@ -576,17 +633,26 @@ func TestContinueSessionInheritance(t *testing.T) {
|
||||
t.Fatalf("Failed to get child MCP servers: %v", err)
|
||||
}
|
||||
|
||||
// Should have the override server, not the original
|
||||
if len(childMCPServers) != 1 {
|
||||
t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers))
|
||||
// Should have the override server plus injected codelayer
|
||||
if len(childMCPServers) != 2 {
|
||||
t.Fatalf("Expected 2 MCP servers (override + codelayer), got %d", len(childMCPServers))
|
||||
}
|
||||
|
||||
server := childMCPServers[0]
|
||||
if server.Name != "override-server" {
|
||||
t.Errorf("MCP server name not overridden: got %s", server.Name)
|
||||
// Find the override server (not codelayer)
|
||||
var overrideServer *store.MCPServer
|
||||
for _, s := range childMCPServers {
|
||||
if s.Name == "override-server" {
|
||||
overrideServer = &s
|
||||
break
|
||||
}
|
||||
}
|
||||
if server.Command != "override-cmd" {
|
||||
t.Errorf("MCP server command not overridden: got %s", server.Command)
|
||||
|
||||
if overrideServer == nil {
|
||||
t.Fatal("Override server not found")
|
||||
}
|
||||
|
||||
if overrideServer.Command != "override-cmd" {
|
||||
t.Errorf("MCP server command not overridden: got %s", overrideServer.Command)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -853,17 +919,25 @@ func TestContinueSessionInheritance(t *testing.T) {
|
||||
t.Fatalf("Failed to get child MCP servers: %v", err)
|
||||
}
|
||||
|
||||
// Should have inherited the MCP server
|
||||
if len(childMCPServers) != 1 {
|
||||
t.Fatalf("Expected 1 MCP server, got %d", len(childMCPServers))
|
||||
// Should have inherited the MCP server plus injected codelayer
|
||||
if len(childMCPServers) != 2 {
|
||||
t.Fatalf("Expected 2 MCP servers (http + codelayer), got %d", len(childMCPServers))
|
||||
}
|
||||
|
||||
childMCPServer := childMCPServers[0]
|
||||
// Find the http test server (not codelayer)
|
||||
var childMCPServer *store.MCPServer
|
||||
for _, s := range childMCPServers {
|
||||
if s.Name == "http-test-server" {
|
||||
childMCPServer = &s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if childMCPServer == nil {
|
||||
t.Fatal("HTTP test server not found")
|
||||
}
|
||||
|
||||
// Verify basic inheritance
|
||||
if childMCPServer.Name != "http-test-server" {
|
||||
t.Errorf("MCP server name not inherited: got %s, want http-test-server", childMCPServer.Name)
|
||||
}
|
||||
if childMCPServer.Command != "http" {
|
||||
t.Errorf("MCP server type not inherited: got %s, want http", childMCPServer.Command)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,26 @@ func (m *Manager) LaunchSession(ctx context.Context, config LaunchSessionConfig)
|
||||
// Extract the Claude config (without daemon-level settings)
|
||||
claudeConfig := config.SessionConfig
|
||||
|
||||
// Inject daemon's CodeLayer MCP server configuration
|
||||
if claudeConfig.MCPConfig == nil {
|
||||
claudeConfig.MCPConfig = &claudecode.MCPConfig{
|
||||
MCPServers: make(map[string]claudecode.MCPServer),
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject codelayer MCP server (overwrite if exists)
|
||||
claudeConfig.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{
|
||||
Command: "hlyr",
|
||||
Args: []string{"mcp", "claude_approvals"},
|
||||
Env: map[string]string{
|
||||
"HUMANLAYER_SESSION_ID": sessionID,
|
||||
"HUMANLAYER_DAEMON_SOCKET": m.socketPath,
|
||||
},
|
||||
}
|
||||
slog.Debug("injected codelayer MCP server",
|
||||
"session_id", sessionID,
|
||||
"socket_path", m.socketPath)
|
||||
|
||||
// Add HUMANLAYER_RUN_ID and HUMANLAYER_DAEMON_SOCKET to MCP server environment
|
||||
// For HTTP servers, inject session ID header
|
||||
if claudeConfig.MCPConfig != nil {
|
||||
@@ -1300,8 +1320,33 @@ func (m *Manager) ContinueSession(ctx context.Context, req ContinueSessionConfig
|
||||
// Add run_id and daemon socket to MCP server environments
|
||||
// For HTTP servers, inject session ID header
|
||||
|
||||
// Ensure MCP config exists for injection
|
||||
if config.MCPConfig == nil {
|
||||
config.MCPConfig = &claudecode.MCPConfig{
|
||||
MCPServers: make(map[string]claudecode.MCPServer),
|
||||
}
|
||||
}
|
||||
|
||||
// Always update codelayer MCP server with child session ID
|
||||
config.MCPConfig.MCPServers["codelayer"] = claudecode.MCPServer{
|
||||
Command: "hlyr",
|
||||
Args: []string{"mcp", "claude_approvals"},
|
||||
Env: map[string]string{
|
||||
"HUMANLAYER_SESSION_ID": sessionID, // Use child session ID
|
||||
"HUMANLAYER_DAEMON_SOCKET": m.socketPath,
|
||||
},
|
||||
}
|
||||
slog.Debug("updated codelayer MCP server for child session",
|
||||
"session_id", sessionID,
|
||||
"parent_session_id", req.ParentSessionID,
|
||||
"socket_path", m.socketPath)
|
||||
|
||||
if config.MCPConfig != nil {
|
||||
for name, server := range config.MCPConfig.MCPServers {
|
||||
// Skip codelayer as we already configured it above
|
||||
if name == "codelayer" {
|
||||
continue
|
||||
}
|
||||
// Check if this is an HTTP MCP server
|
||||
if server.Type == "http" {
|
||||
// For HTTP servers, always set session ID header to child session ID
|
||||
|
||||
@@ -337,11 +337,25 @@ func TestLaunchSession_SetsMCPEnvironment(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify MCP servers have the correct environment variables
|
||||
if len(capturedMCPServers) != 1 {
|
||||
t.Fatalf("Expected 1 MCP server, got %d", len(capturedMCPServers))
|
||||
// Should have the test server plus injected codelayer
|
||||
if len(capturedMCPServers) != 2 {
|
||||
t.Fatalf("Expected 2 MCP servers (test + codelayer), got %d", len(capturedMCPServers))
|
||||
}
|
||||
|
||||
server := capturedMCPServers[0]
|
||||
// Find the test server (not codelayer)
|
||||
var server store.MCPServer
|
||||
var found bool
|
||||
for _, s := range capturedMCPServers {
|
||||
if s.Name == "test-server" {
|
||||
server = s
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatal("Test server not found in MCP servers")
|
||||
}
|
||||
|
||||
// Parse the environment JSON
|
||||
var env map[string]string
|
||||
|
||||
@@ -45,22 +45,6 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
const client = await connectWithRetry(socketPath, 3, 1000)
|
||||
|
||||
try {
|
||||
// Build MCP config (approvals enabled by default unless explicitly disabled)
|
||||
// Phase 6: Using HTTP MCP endpoint instead of stdio
|
||||
const daemonPort = process.env.HUMANLAYER_DAEMON_HTTP_PORT || '7777'
|
||||
const mcpConfig =
|
||||
options.approvals !== false
|
||||
? {
|
||||
mcpServers: {
|
||||
codelayer: {
|
||||
type: 'http',
|
||||
url: `http://localhost:${daemonPort}/api/v1/mcp`,
|
||||
// Session ID will be added as header by Claude Code
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Launch the session
|
||||
const result = await client.launchSession({
|
||||
query: query,
|
||||
@@ -68,8 +52,9 @@ export const launchCommand = async (query: string, options: LaunchOptions = {})
|
||||
model: options.model,
|
||||
working_dir: options.workingDir || process.cwd(),
|
||||
max_turns: options.maxTurns,
|
||||
mcp_config: mcpConfig,
|
||||
permission_prompt_tool: mcpConfig ? 'mcp__codelayer__request_approval' : undefined,
|
||||
// MCP config is now injected by daemon
|
||||
permission_prompt_tool:
|
||||
options.approvals !== false ? 'mcp__codelayer__request_permission' : undefined,
|
||||
dangerously_skip_permissions: options.dangerouslySkipPermissions,
|
||||
dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout
|
||||
? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1000
|
||||
|
||||
@@ -353,11 +353,13 @@ export class DaemonClient extends EventEmitter {
|
||||
runId: string,
|
||||
toolName: string,
|
||||
toolInput: unknown,
|
||||
toolUseId?: string,
|
||||
): Promise<{ approval_id: string }> {
|
||||
return this.call<{ approval_id: string }>('createApproval', {
|
||||
run_id: runId,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
...(toolUseId && { tool_use_id: toolUseId }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { launchCommand } from './commands/launch.js'
|
||||
import { alertCommand } from './commands/alert.js'
|
||||
import { thoughtsCommand } from './commands/thoughts.js'
|
||||
import { joinWaitlistCommand } from './commands/joinWaitlist.js'
|
||||
import { startClaudeApprovalsMCPServer } from './mcp.js'
|
||||
import {
|
||||
getDefaultConfigPath,
|
||||
resolveFullConfig,
|
||||
@@ -66,7 +67,7 @@ async function authenticate(printSelectedProject: boolean = false) {
|
||||
|
||||
program.name('humanlayer').description('HumanLayer, but on your command-line.').version(VERSION)
|
||||
|
||||
const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch']
|
||||
const UNPROTECTED_COMMANDS = ['config', 'login', 'thoughts', 'join-waitlist', 'launch', 'mcp']
|
||||
|
||||
program.hook('preAction', async (thisCmd, actionCmd) => {
|
||||
// Get the full command path by traversing up the command hierarchy
|
||||
@@ -96,6 +97,13 @@ program
|
||||
.option('--config-file <path>', 'Path to config file')
|
||||
.action(loginCommand)
|
||||
|
||||
const mcpCommand = program.command('mcp').description('MCP server functionality')
|
||||
|
||||
mcpCommand
|
||||
.command('claude_approvals')
|
||||
.description('Start the Claude approvals MCP server for permission requests')
|
||||
.action(startClaudeApprovalsMCPServer)
|
||||
|
||||
program
|
||||
.command('launch <query>')
|
||||
.description('Launch a new Claude Code session via the daemon')
|
||||
|
||||
192
hlyr/src/mcp.ts
Normal file
192
hlyr/src/mcp.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { resolveFullConfig } from './config.js'
|
||||
import { DaemonClient } from './daemonClient.js'
|
||||
import { logger } from './mcpLogger.js'
|
||||
|
||||
/**
|
||||
* Start the Claude approvals MCP server that provides request_permission functionality
|
||||
* Returns responses in the format required by Claude Code SDK
|
||||
*
|
||||
* This uses local approvals through the daemon instead of HumanLayer API
|
||||
*/
|
||||
export async function startClaudeApprovalsMCPServer() {
|
||||
// No auth validation needed - uses local daemon
|
||||
logger.info('Starting Claude approvals MCP server')
|
||||
logger.info('Environment variables', {
|
||||
HUMANLAYER_DAEMON_SOCKET: process.env.HUMANLAYER_DAEMON_SOCKET,
|
||||
HUMANLAYER_SESSION_ID: process.env.HUMANLAYER_SESSION_ID,
|
||||
})
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'humanlayer-claude-local-approvals',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Create daemon client with socket path from environment or config
|
||||
// The daemon sets HUMANLAYER_DAEMON_SOCKET for MCP servers it launches
|
||||
const resolvedConfig = resolveFullConfig({})
|
||||
const socketPath = process.env.HUMANLAYER_DAEMON_SOCKET || resolvedConfig.daemon_socket
|
||||
logger.info('Creating daemon client', { socketPath })
|
||||
const daemonClient = new DaemonClient(socketPath)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
logger.info('ListTools request received')
|
||||
const tools = [
|
||||
{
|
||||
name: 'request_permission',
|
||||
description: 'Request permission to perform an action',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tool_name: { type: 'string' },
|
||||
input: { type: 'object' },
|
||||
tool_use_id: { type: 'string' }, // Added for Phase 2
|
||||
},
|
||||
required: ['tool_name', 'input', 'tool_use_id'],
|
||||
},
|
||||
},
|
||||
]
|
||||
logger.info('Returning tools', { tools })
|
||||
return { tools }
|
||||
})
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||
logger.debug('Received tool call request', { name: request.params.name })
|
||||
|
||||
if (request.params.name === 'request_permission') {
|
||||
const toolName: string | undefined = request.params.arguments?.tool_name
|
||||
const toolUseId: string | undefined = request.params.arguments?.tool_use_id // Phase 2
|
||||
|
||||
if (!toolName) {
|
||||
logger.error('Invalid tool name in request_permission', request.params.arguments)
|
||||
throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name requesting permissions')
|
||||
}
|
||||
|
||||
const input: Record<string, unknown> = request.params.arguments?.input || {}
|
||||
|
||||
// Get session ID from environment (set by daemon)
|
||||
const sessionId = process.env.HUMANLAYER_SESSION_ID
|
||||
if (!sessionId) {
|
||||
logger.error('HUMANLAYER_SESSION_ID not set in environment')
|
||||
throw new McpError(ErrorCode.InternalError, 'HUMANLAYER_SESSION_ID not set')
|
||||
}
|
||||
|
||||
logger.info('Processing approval request', { sessionId, toolName, toolUseId })
|
||||
|
||||
try {
|
||||
// Connect to daemon
|
||||
logger.debug('Connecting to daemon...')
|
||||
await daemonClient.connect()
|
||||
logger.debug('Connected to daemon')
|
||||
|
||||
// Create approval request with tool use ID (Phase 2)
|
||||
logger.debug('Creating approval request...', { sessionId, toolName, toolUseId })
|
||||
const createResponse = await daemonClient.createApproval(sessionId, toolName, input, toolUseId)
|
||||
const approvalId = createResponse.approval_id
|
||||
logger.info('Created approval', { approvalId })
|
||||
|
||||
// Poll for approval status
|
||||
let approved = false
|
||||
let comment = ''
|
||||
let polling = true
|
||||
|
||||
while (polling) {
|
||||
try {
|
||||
// Get the specific approval by ID
|
||||
logger.debug('Fetching approval status...', { approvalId })
|
||||
const approval = (await daemonClient.getApproval(approvalId)) as {
|
||||
id: string
|
||||
status: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
logger.debug('Approval status', { status: approval.status })
|
||||
|
||||
if (approval.status !== 'pending') {
|
||||
// Approval has been resolved
|
||||
approved = approval.status === 'approved'
|
||||
comment = approval.comment || ''
|
||||
polling = false
|
||||
logger.info('Approval resolved', {
|
||||
approvalId,
|
||||
status: approval.status,
|
||||
approved,
|
||||
})
|
||||
} else {
|
||||
// Still pending, wait and poll again
|
||||
logger.debug('Approval still pending, polling again...')
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get approval status', { error, approvalId })
|
||||
// Re-throw the error since this is a critical failure
|
||||
throw new McpError(ErrorCode.InternalError, 'Failed to get approval status')
|
||||
}
|
||||
}
|
||||
|
||||
if (!approved) {
|
||||
logger.info('Approval denied', { approvalId, comment })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
behavior: 'deny',
|
||||
message: comment || 'Request denied by human reviewer',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Approval granted', { approvalId })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to process approval', error)
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Failed to process approval: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
} finally {
|
||||
logger.debug('Closing daemon connection')
|
||||
daemonClient.close()
|
||||
}
|
||||
}
|
||||
|
||||
throw new McpError(ErrorCode.InvalidRequest, 'Invalid tool name')
|
||||
})
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
|
||||
try {
|
||||
await server.connect(transport)
|
||||
logger.info('MCP server connected and ready')
|
||||
} catch (error) {
|
||||
logger.error('Failed to start MCP server', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -118,8 +118,14 @@ export function DebugPanel({ open, onOpenChange }: DebugPanelProps) {
|
||||
async function handleConnectToCustom() {
|
||||
setConnectError(null)
|
||||
|
||||
let url = customUrl.trim()
|
||||
|
||||
if (!isNaN(Number(url))) {
|
||||
url = `http://127.0.0.1:${url}`
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonService.connectToExisting(customUrl)
|
||||
await daemonService.connectToExisting(url)
|
||||
await reconnect()
|
||||
await loadDaemonInfo()
|
||||
setCustomUrl('')
|
||||
@@ -221,7 +227,7 @@ export function DebugPanel({ open, onOpenChange }: DebugPanelProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Connect to Existing Daemon</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Connect to a daemon running on a custom URL
|
||||
Connect to a daemon running on a custom URL (or provide a port number).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
||||
@@ -296,6 +296,20 @@ export function Layout() {
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent escape key from exiting full screen
|
||||
// Might be worth guarding this specifically in macOS
|
||||
// down-the-road
|
||||
useHotkeys(
|
||||
'escape',
|
||||
() => {
|
||||
// console.log('escape!', ev);
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
)
|
||||
|
||||
// Load sessions when connected
|
||||
useEffect(() => {
|
||||
if (connected) {
|
||||
|
||||
@@ -147,7 +147,10 @@ export function useSessionActions({
|
||||
interruptSession(session.id)
|
||||
}
|
||||
},
|
||||
{ scopes: SessionDetailHotkeysScope },
|
||||
{
|
||||
scopes: SessionDetailHotkeysScope,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
)
|
||||
|
||||
// R key - no longer needed since input is always visible
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import type { LaunchSessionRequest } from '@/lib/daemon/types'
|
||||
import { getDaemonUrl } from '@/lib/daemon/http-config'
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook'
|
||||
import { SessionTableHotkeysScope } from '@/components/internal/SessionTable'
|
||||
import { exists } from '@tauri-apps/plugin-fs'
|
||||
@@ -141,17 +140,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
try {
|
||||
set({ isLaunching: true, error: undefined })
|
||||
|
||||
// Build MCP config (approvals enabled by default)
|
||||
// Use HTTP-based MCP server built into the daemon
|
||||
const daemonUrl = await getDaemonUrl()
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
approvals: {
|
||||
type: 'http',
|
||||
url: `${daemonUrl}/api/v1/mcp`,
|
||||
},
|
||||
},
|
||||
}
|
||||
// MCP config is now injected by daemon
|
||||
|
||||
const request: LaunchSessionRequest = {
|
||||
query: query.trim(),
|
||||
@@ -159,8 +148,8 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
working_dir: config.workingDir || undefined,
|
||||
model: config.model || undefined,
|
||||
max_turns: config.maxTurns || undefined,
|
||||
mcp_config: mcpConfig,
|
||||
permission_prompt_tool: 'mcp__approvals__request_approval',
|
||||
// MCP config is now injected by daemon
|
||||
permission_prompt_tool: 'mcp__codelayer__request_permission',
|
||||
}
|
||||
|
||||
const response = await daemonClient.launchSession(request)
|
||||
|
||||
@@ -102,6 +102,21 @@
|
||||
/* Custom bullets for unordered lists */
|
||||
.prose-terminal ul {
|
||||
list-style: none;
|
||||
|
||||
/* This is slightly a hack, we're making heavy use of `pre-wrap`
|
||||
and we can potentially remove this in the future. For now it helps
|
||||
make sure we're not rendering extra space around list items */
|
||||
white-space: normal;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.prose-terminal ul > li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.prose-terminal ul > li::before {
|
||||
|
||||
Reference in New Issue
Block a user