Merge pull request 'fix: feat: hire-an-agent should support local models (--local-model flag) (#521)' (#530) from fix/issue-521 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
cb832f5bf6
4 changed files with 122 additions and 81 deletions
|
|
@ -51,7 +51,7 @@ Usage:
|
||||||
disinto ci-logs <pipeline> [--step <name>]
|
disinto ci-logs <pipeline> [--step <name>]
|
||||||
Read CI logs from Woodpecker SQLite
|
Read CI logs from Woodpecker SQLite
|
||||||
disinto release <version> Create vault PR for release (e.g., v1.2.0)
|
disinto release <version> Create vault PR for release (e.g., v1.2.0)
|
||||||
disinto hire-an-agent <agent-name> <role> [--formula <path>]
|
disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--model <name>]
|
||||||
Hire a new agent (create user + .profile repo)
|
Hire a new agent (create user + .profile repo)
|
||||||
|
|
||||||
Init options:
|
Init options:
|
||||||
|
|
@ -64,6 +64,9 @@ Init options:
|
||||||
|
|
||||||
Hire an agent options:
|
Hire an agent options:
|
||||||
--formula <path> Path to role formula TOML (default: formulas/<role>.toml)
|
--formula <path> Path to role formula TOML (default: formulas/<role>.toml)
|
||||||
|
--local-model <url> Base URL for local model server (e.g., http://10.10.10.1:8081)
|
||||||
|
--model <name> Model name for local model (e.g., unsloth/Qwen3.5-35B-A3B)
|
||||||
|
--poll-interval <s> Poll interval in seconds (default: 60)
|
||||||
|
|
||||||
CI logs options:
|
CI logs options:
|
||||||
--step <name> Filter logs to a specific step (e.g., smoke-init)
|
--step <name> Filter logs to a specific step (e.g., smoke-init)
|
||||||
|
|
@ -370,6 +373,7 @@ check_pipeline_stall = false
|
||||||
# roles = ["dev"]
|
# roles = ["dev"]
|
||||||
# forge_user = "dev-qwen"
|
# forge_user = "dev-qwen"
|
||||||
# compact_pct = 60
|
# compact_pct = 60
|
||||||
|
# poll_interval = 60
|
||||||
|
|
||||||
# [mirrors]
|
# [mirrors]
|
||||||
# github = "git@github.com:user/repo.git"
|
# github = "git@github.com:user/repo.git"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ _generate_local_model_services() {
|
||||||
API_KEY) api_key="$value" ;;
|
API_KEY) api_key="$value" ;;
|
||||||
FORGE_USER) forge_user="$value" ;;
|
FORGE_USER) forge_user="$value" ;;
|
||||||
COMPACT_PCT) compact_pct="$value" ;;
|
COMPACT_PCT) compact_pct="$value" ;;
|
||||||
|
POLL_INTERVAL) poll_interval_val="$value" ;;
|
||||||
---)
|
---)
|
||||||
if [ -n "$service_name" ] && [ -n "$base_url" ]; then
|
if [ -n "$service_name" ] && [ -n "$base_url" ]; then
|
||||||
cat >> "$temp_file" <<EOF
|
cat >> "$temp_file" <<EOF
|
||||||
|
|
@ -85,6 +86,7 @@ _generate_local_model_services() {
|
||||||
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
||||||
WOODPECKER_DATA_DIR: /woodpecker-data
|
WOODPECKER_DATA_DIR: /woodpecker-data
|
||||||
FORGE_BOT_USER_${service_name^^}: "${forge_user}"
|
FORGE_BOT_USER_${service_name^^}: "${forge_user}"
|
||||||
|
POLL_INTERVAL: "${poll_interval_val}"
|
||||||
depends_on:
|
depends_on:
|
||||||
- forgejo
|
- forgejo
|
||||||
- woodpecker
|
- woodpecker
|
||||||
|
|
@ -103,7 +105,7 @@ ${vol_name}"
|
||||||
else
|
else
|
||||||
all_vols="${vol_name}"
|
all_vols="${vol_name}"
|
||||||
fi
|
fi
|
||||||
service_name="" base_url="" model="" roles="" api_key="" forge_user="" compact_pct=""
|
service_name="" base_url="" model="" roles="" api_key="" forge_user="" compact_pct="" poll_interval_val=""
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done < <(python3 -c '
|
done < <(python3 -c '
|
||||||
|
|
@ -127,6 +129,7 @@ for name, config in agents.items():
|
||||||
api_key = config.get("api_key", "sk-no-key-required")
|
api_key = config.get("api_key", "sk-no-key-required")
|
||||||
forge_user = config.get("forge_user", f"{name}-bot")
|
forge_user = config.get("forge_user", f"{name}-bot")
|
||||||
compact_pct = config.get("compact_pct", 60)
|
compact_pct = config.get("compact_pct", 60)
|
||||||
|
poll_interval = config.get("poll_interval", 60)
|
||||||
|
|
||||||
safe_name = name.lower()
|
safe_name = name.lower()
|
||||||
safe_name = re.sub(r"[^a-z0-9]", "-", safe_name)
|
safe_name = re.sub(r"[^a-z0-9]", "-", safe_name)
|
||||||
|
|
@ -139,6 +142,7 @@ for name, config in agents.items():
|
||||||
print(f"API_KEY={api_key}")
|
print(f"API_KEY={api_key}")
|
||||||
print(f"FORGE_USER={forge_user}")
|
print(f"FORGE_USER={forge_user}")
|
||||||
print(f"COMPACT_PCT={compact_pct}")
|
print(f"COMPACT_PCT={compact_pct}")
|
||||||
|
print(f"POLL_INTERVAL={poll_interval}")
|
||||||
print("---")
|
print("---")
|
||||||
' "$toml" 2>/dev/null)
|
' "$toml" 2>/dev/null)
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# source "${FACTORY_ROOT}/lib/hire-agent.sh"
|
# source "${FACTORY_ROOT}/lib/hire-agent.sh"
|
||||||
# disinto_hire_an_agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--poll-interval <seconds>]
|
# disinto_hire_an_agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--model <name>] [--poll-interval <seconds>]
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -22,11 +22,12 @@ disinto_hire_an_agent() {
|
||||||
local role="${2:-}"
|
local role="${2:-}"
|
||||||
local formula_path=""
|
local formula_path=""
|
||||||
local local_model=""
|
local local_model=""
|
||||||
|
local model_name=""
|
||||||
local poll_interval=""
|
local poll_interval=""
|
||||||
|
|
||||||
if [ -z "$agent_name" ] || [ -z "$role" ]; then
|
if [ -z "$agent_name" ] || [ -z "$role" ]; then
|
||||||
echo "Error: agent-name and role required" >&2
|
echo "Error: agent-name and role required" >&2
|
||||||
echo "Usage: disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--poll-interval <seconds>]" >&2
|
echo "Usage: disinto hire-an-agent <agent-name> <role> [--formula <path>] [--local-model <url>] [--model <name>] [--poll-interval <seconds>]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -42,6 +43,10 @@ disinto_hire_an_agent() {
|
||||||
local_model="$2"
|
local_model="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--model)
|
||||||
|
model_name="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--poll-interval)
|
--poll-interval)
|
||||||
poll_interval="$2"
|
poll_interval="$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -71,7 +76,8 @@ disinto_hire_an_agent() {
|
||||||
echo "Formula: ${formula_path}"
|
echo "Formula: ${formula_path}"
|
||||||
if [ -n "$local_model" ]; then
|
if [ -n "$local_model" ]; then
|
||||||
echo "Local model: ${local_model}"
|
echo "Local model: ${local_model}"
|
||||||
echo "Poll interval: ${poll_interval:-300}s"
|
echo "Model name: ${model_name:-local-model}"
|
||||||
|
echo "Poll interval: ${poll_interval:-60}s"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure FORGE_TOKEN is set
|
# Ensure FORGE_TOKEN is set
|
||||||
|
|
@ -367,11 +373,6 @@ EOF
|
||||||
echo ""
|
echo ""
|
||||||
echo "Step 6: Configuring local model agent..."
|
echo "Step 6: Configuring local model agent..."
|
||||||
|
|
||||||
local override_file="${FACTORY_ROOT}/docker-compose.override.yml"
|
|
||||||
local override_dir
|
|
||||||
override_dir=$(dirname "$override_file")
|
|
||||||
mkdir -p "$override_dir"
|
|
||||||
|
|
||||||
# Validate model endpoint is reachable
|
# Validate model endpoint is reachable
|
||||||
echo " Validating model endpoint: ${local_model}"
|
echo " Validating model endpoint: ${local_model}"
|
||||||
if ! curl -sf --max-time 10 "${local_model}/health" >/dev/null 2>&1; then
|
if ! curl -sf --max-time 10 "${local_model}/health" >/dev/null 2>&1; then
|
||||||
|
|
@ -384,83 +385,114 @@ EOF
|
||||||
echo " Model endpoint is reachable"
|
echo " Model endpoint is reachable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate service name from agent name (lowercase)
|
# Find project TOML
|
||||||
local service_name="agents-${agent_name}"
|
local project_name="${PROJECT_NAME:-}"
|
||||||
service_name=$(echo "$service_name" | tr '[:upper:]' '[:lower:]')
|
local toml_file=""
|
||||||
|
if [ -n "$project_name" ]; then
|
||||||
# Set default poll interval
|
toml_file="${FACTORY_ROOT}/projects/${project_name}.toml"
|
||||||
local interval="${poll_interval:-300}"
|
fi
|
||||||
|
# Fallback: find the first .toml in projects/
|
||||||
# Generate the override compose file
|
if [ -z "$toml_file" ] || [ ! -f "$toml_file" ]; then
|
||||||
# Bash expands ${service_name}, ${local_model}, ${interval}, ${PROJECT_NAME} at generation time
|
for f in "${FACTORY_ROOT}/projects/"*.toml; do
|
||||||
# \$HOME, \$FORGE_TOKEN become ${HOME}, ${FORGE_TOKEN} in the file for docker-compose runtime expansion
|
if [ -f "$f" ]; then
|
||||||
cat > "$override_file" <<OVERRIDEOF
|
toml_file="$f"
|
||||||
# docker-compose.override.yml — auto-generated by disinto hire-an-agent
|
break
|
||||||
# Local model agent configuration for ${agent_name}
|
fi
|
||||||
|
done
|
||||||
services:
|
|
||||||
${service_name}:
|
|
||||||
image: disinto-agents:latest
|
|
||||||
profiles: ["local-model"]
|
|
||||||
restart: unless-stopped
|
|
||||||
security_opt:
|
|
||||||
- apparmor=unconfined
|
|
||||||
volumes:
|
|
||||||
- agent-data-llama:/home/agent/data
|
|
||||||
- project-repos-llama:/home/agent/repos
|
|
||||||
- \$HOME/.claude:/home/agent/.claude
|
|
||||||
- \$HOME/.claude.json:/home/agent/.claude.json:ro
|
|
||||||
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
|
|
||||||
- \$HOME/.ssh:/home/agent/.ssh:ro
|
|
||||||
- \$HOME/.config/sops/age:/home/agent/.config/sops/age:ro
|
|
||||||
environment:
|
|
||||||
FORGE_URL: http://forgejo:3000
|
|
||||||
FORGE_TOKEN: ${FORGE_TOKEN_DEVQWEN:-}
|
|
||||||
FORGE_SUPERVISOR_TOKEN: ${FORGE_SUPERVISOR_TOKEN:-}
|
|
||||||
FORGE_PREDICTOR_TOKEN: ${FORGE_PREDICTOR_TOKEN:-}
|
|
||||||
FORGE_ARCHITECT_TOKEN: ${FORGE_ARCHITECT_TOKEN:-}
|
|
||||||
FORGE_VAULT_TOKEN: ${FORGE_VAULT_TOKEN:-}
|
|
||||||
FORGE_PLANNER_TOKEN: ${FORGE_PLANNER_TOKEN:-}
|
|
||||||
FORGE_BOT_USERNAMES: ${FORGE_BOT_USERNAMES:-}
|
|
||||||
WOODPECKER_TOKEN: ${WOODPECKER_TOKEN:-}
|
|
||||||
CLAUDE_TIMEOUT: ${CLAUDE_TIMEOUT:-7200}
|
|
||||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: ${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}
|
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
||||||
ANTHROPIC_BASE_URL: ${local_model}
|
|
||||||
FORGE_ADMIN_PASS: ${FORGE_ADMIN_PASS:-}
|
|
||||||
DISINTO_CONTAINER: "1"
|
|
||||||
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
|
|
||||||
WOODPECKER_DATA_DIR: /woodpecker-data
|
|
||||||
AGENT_ROLES: dev
|
|
||||||
CLAUDE_CONFIG_DIR: /home/agent/.claude
|
|
||||||
POLL_INTERVAL: ${interval}
|
|
||||||
depends_on:
|
|
||||||
- forgejo
|
|
||||||
- woodpecker
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
agent-data-llama:
|
|
||||||
project-repos-llama:
|
|
||||||
OVERRIDEOF
|
|
||||||
|
|
||||||
# Patch the Claude CLI binary path
|
|
||||||
local claude_bin
|
|
||||||
claude_bin="$(command -v claude 2>/dev/null || true)"
|
|
||||||
if [ -n "$claude_bin" ]; then
|
|
||||||
claude_bin="$(readlink -f "$claude_bin")"
|
|
||||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|" "$override_file"
|
|
||||||
else
|
|
||||||
echo " Warning: claude CLI not found — update override file manually"
|
|
||||||
sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|" "$override_file"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo " Created: ${override_file}"
|
if [ -z "$toml_file" ] || [ ! -f "$toml_file" ]; then
|
||||||
|
echo " Error: no project TOML found in ${FACTORY_ROOT}/projects/" >&2
|
||||||
|
echo " Run 'disinto init' first to create a project config" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Project TOML: ${toml_file}"
|
||||||
|
|
||||||
|
# Derive a safe section name from the agent name (lowercase, alphanumeric+hyphens)
|
||||||
|
local section_name
|
||||||
|
section_name=$(echo "$agent_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')
|
||||||
|
|
||||||
|
# Default model name if not provided
|
||||||
|
local model="${model_name:-local-model}"
|
||||||
|
|
||||||
|
# Write [agents.<name>] section to the project TOML
|
||||||
|
local interval="${poll_interval:-60}"
|
||||||
|
echo " Writing [agents.${section_name}] to ${toml_file}..."
|
||||||
|
python3 -c '
|
||||||
|
import sys, re, pathlib
|
||||||
|
|
||||||
|
toml_path = sys.argv[1]
|
||||||
|
section_name = sys.argv[2]
|
||||||
|
base_url = sys.argv[3]
|
||||||
|
model = sys.argv[4]
|
||||||
|
agent_name = sys.argv[5]
|
||||||
|
role = sys.argv[6]
|
||||||
|
poll_interval = sys.argv[7]
|
||||||
|
|
||||||
|
p = pathlib.Path(toml_path)
|
||||||
|
text = p.read_text()
|
||||||
|
|
||||||
|
# Build the new section
|
||||||
|
new_section = f"""
|
||||||
|
[agents.{section_name}]
|
||||||
|
base_url = "{base_url}"
|
||||||
|
model = "{model}"
|
||||||
|
api_key = "sk-no-key-required"
|
||||||
|
roles = ["{role}"]
|
||||||
|
forge_user = "{agent_name}"
|
||||||
|
compact_pct = 60
|
||||||
|
poll_interval = {poll_interval}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check if section already exists and replace it
|
||||||
|
pattern = rf"\[agents\.{re.escape(section_name)}\][^\[]*"
|
||||||
|
if re.search(pattern, text):
|
||||||
|
text = re.sub(pattern, new_section.strip() + "\n", text)
|
||||||
|
else:
|
||||||
|
# Remove commented-out example [agents.llama] block if present
|
||||||
|
text = re.sub(
|
||||||
|
r"\n# Local-model agents \(optional\).*?(?=\n# \[mirrors\]|\n\[mirrors\]|\Z)",
|
||||||
|
"",
|
||||||
|
text,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
# Append before [mirrors] if it exists, otherwise at end
|
||||||
|
mirrors_match = re.search(r"\n(# )?\[mirrors\]", text)
|
||||||
|
if mirrors_match:
|
||||||
|
text = text[:mirrors_match.start()] + "\n" + new_section + text[mirrors_match.start():]
|
||||||
|
else:
|
||||||
|
text = text.rstrip() + "\n" + new_section
|
||||||
|
|
||||||
|
p.write_text(text)
|
||||||
|
' "$toml_file" "$section_name" "$local_model" "$model" "$agent_name" "$role" "$interval"
|
||||||
|
|
||||||
|
echo " Agent config written to TOML"
|
||||||
|
|
||||||
|
# Regenerate docker-compose.yml to include the new agent container
|
||||||
|
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
|
||||||
|
if [ -f "$compose_file" ]; then
|
||||||
|
echo " Regenerating docker-compose.yml..."
|
||||||
|
rm -f "$compose_file"
|
||||||
|
# generate_compose is defined in the calling script (bin/disinto) via generators.sh
|
||||||
|
# Use _generate_compose_impl directly since generators.sh is already sourced
|
||||||
|
local forge_port="3000"
|
||||||
|
if [ -n "${FORGE_URL:-}" ]; then
|
||||||
|
forge_port=$(printf '%s' "$FORGE_URL" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
||||||
|
forge_port="${forge_port:-3000}"
|
||||||
|
fi
|
||||||
|
_generate_compose_impl "$forge_port"
|
||||||
|
echo " Compose regenerated with agents-${section_name} service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local service_name="agents-${section_name}"
|
||||||
|
echo ""
|
||||||
echo " Service name: ${service_name}"
|
echo " Service name: ${service_name}"
|
||||||
echo " Poll interval: ${interval}s"
|
|
||||||
echo " Model endpoint: ${local_model}"
|
echo " Model endpoint: ${local_model}"
|
||||||
|
echo " Model: ${model}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " To start the agent, run:"
|
echo " To start the agent, run:"
|
||||||
echo " docker compose --profile local-model up -d ${service_name}"
|
echo " docker compose --profile ${service_name} up -d ${service_name}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ check_pipeline_stall = false
|
||||||
# roles = ["dev"]
|
# roles = ["dev"]
|
||||||
# forge_user = "dev-qwen"
|
# forge_user = "dev-qwen"
|
||||||
# compact_pct = 60
|
# compact_pct = 60
|
||||||
|
# poll_interval = 60
|
||||||
|
|
||||||
# [mirrors]
|
# [mirrors]
|
||||||
# github = "git@github.com:johba/disinto.git"
|
# github = "git@github.com:johba/disinto.git"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue