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

This commit is contained in:
dev-bot 2026-04-10 06:03:32 +00:00
commit cb832f5bf6
4 changed files with 122 additions and 81 deletions

View file

@ -51,7 +51,7 @@ Usage:
disinto ci-logs <pipeline> [--step <name>]
Read CI logs from Woodpecker SQLite
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)
Init options:
@ -64,6 +64,9 @@ Init options:
Hire an agent options:
--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:
--step <name> Filter logs to a specific step (e.g., smoke-init)
@ -370,6 +373,7 @@ check_pipeline_stall = false
# roles = ["dev"]
# forge_user = "dev-qwen"
# compact_pct = 60
# poll_interval = 60
# [mirrors]
# github = "git@github.com:user/repo.git"

View file

@ -50,6 +50,7 @@ _generate_local_model_services() {
API_KEY) api_key="$value" ;;
FORGE_USER) forge_user="$value" ;;
COMPACT_PCT) compact_pct="$value" ;;
POLL_INTERVAL) poll_interval_val="$value" ;;
---)
if [ -n "$service_name" ] && [ -n "$base_url" ]; then
cat >> "$temp_file" <<EOF
@ -85,6 +86,7 @@ _generate_local_model_services() {
PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
WOODPECKER_DATA_DIR: /woodpecker-data
FORGE_BOT_USER_${service_name^^}: "${forge_user}"
POLL_INTERVAL: "${poll_interval_val}"
depends_on:
- forgejo
- woodpecker
@ -103,7 +105,7 @@ ${vol_name}"
else
all_vols="${vol_name}"
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
done < <(python3 -c '
@ -127,6 +129,7 @@ for name, config in agents.items():
api_key = config.get("api_key", "sk-no-key-required")
forge_user = config.get("forge_user", f"{name}-bot")
compact_pct = config.get("compact_pct", 60)
poll_interval = config.get("poll_interval", 60)
safe_name = name.lower()
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"FORGE_USER={forge_user}")
print(f"COMPACT_PCT={compact_pct}")
print(f"POLL_INTERVAL={poll_interval}")
print("---")
' "$toml" 2>/dev/null)
done

View file

@ -13,7 +13,7 @@
#
# Usage:
# 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
@ -22,11 +22,12 @@ disinto_hire_an_agent() {
local role="${2:-}"
local formula_path=""
local local_model=""
local model_name=""
local poll_interval=""
if [ -z "$agent_name" ] || [ -z "$role" ]; then
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
fi
shift 2
@ -42,6 +43,10 @@ disinto_hire_an_agent() {
local_model="$2"
shift 2
;;
--model)
model_name="$2"
shift 2
;;
--poll-interval)
poll_interval="$2"
shift 2
@ -71,7 +76,8 @@ disinto_hire_an_agent() {
echo "Formula: ${formula_path}"
if [ -n "$local_model" ]; then
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
# Ensure FORGE_TOKEN is set
@ -367,11 +373,6 @@ EOF
echo ""
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
echo " Validating model endpoint: ${local_model}"
if ! curl -sf --max-time 10 "${local_model}/health" >/dev/null 2>&1; then
@ -384,83 +385,114 @@ EOF
echo " Model endpoint is reachable"
fi
# Generate service name from agent name (lowercase)
local service_name="agents-${agent_name}"
service_name=$(echo "$service_name" | tr '[:upper:]' '[:lower:]')
# Set default poll interval
local interval="${poll_interval:-300}"
# Generate the override compose file
# Bash expands ${service_name}, ${local_model}, ${interval}, ${PROJECT_NAME} at generation time
# \$HOME, \$FORGE_TOKEN become ${HOME}, ${FORGE_TOKEN} in the file for docker-compose runtime expansion
cat > "$override_file" <<OVERRIDEOF
# docker-compose.override.yml — auto-generated by disinto hire-an-agent
# Local model agent configuration for ${agent_name}
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"
# Find project TOML
local project_name="${PROJECT_NAME:-}"
local toml_file=""
if [ -n "$project_name" ]; then
toml_file="${FACTORY_ROOT}/projects/${project_name}.toml"
fi
# Fallback: find the first .toml in projects/
if [ -z "$toml_file" ] || [ ! -f "$toml_file" ]; then
for f in "${FACTORY_ROOT}/projects/"*.toml; do
if [ -f "$f" ]; then
toml_file="$f"
break
fi
done
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 " Poll interval: ${interval}s"
echo " Model endpoint: ${local_model}"
echo " Model: ${model}"
echo ""
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
echo ""

View file

@ -34,6 +34,7 @@ check_pipeline_stall = false
# roles = ["dev"]
# forge_user = "dev-qwen"
# compact_pct = 60
# poll_interval = 60
# [mirrors]
# github = "git@github.com:johba/disinto.git"