feat(ai-tools): server-side web-research + contacts for agents

Two major tool expansions — the Recherche-Agent and Today-Agent can
now research the web autonomously (no browser needed), and a future
Meeting-Prep agent can read + create contacts.

=== research_news (server-side execution) ===

The biggest addition: mana-ai can now call mana-api's news-research
endpoints (POST /discover + /search) directly, without a browser.

Infrastructure:
- services/mana-ai/src/planner/news-research-client.ts — full HTTP
  client with discover→search pipeline. 15s/30s timeouts. Graceful
  null on any failure (network, mana-api down, bad response) so the
  tick never crashes from research errors.
- config.manaApiUrl added (default http://localhost:3060); wired in
  docker-compose.macmini.yml as http://mana-api:3060 + depends_on
  mana-api with service_healthy condition.

Pre-planning research step (cron/tick.ts):
- Before the planner prompt is built, the tick checks if the
  mission's objective or conceptMarkdown matches research keywords
  (same RESEARCH_TRIGGER regex the webapp uses). When it matches:
  * NewsResearchClient.research(objective) runs discovery + search
  * Results are injected as a synthetic ResolvedInput with id
    '__web-research__' and a formatted markdown context block
  * The Planner then sees real article URLs/titles/excerpts and can
    reference them in create_note / save_news_article steps
  * Log line: "pre-research: N feeds, M articles"

Tool registration:
- research_news added to AI_PROPOSABLE_TOOL_NAMES + mana-ai tools.ts
  with params (query, language?, limit?). This lets the planner also
  explicitly propose a research step as a PlanStep (in addition to
  the pre-planning auto-injection).

=== create_contact ===

- Added to AI_PROPOSABLE_TOOL_NAMES + mana-ai tools.ts with params
  (firstName required, lastName/email/phone/company/notes optional).
- Contacts are encrypted at rest; server planner can plan the step
  but execution stays on the webapp (same as all propose tools).
  Full server-side contact resolution via Key-Grant is a future
  enhancement.
- get_contacts added to webapp AUTO_TOOLS so agents can inspect
  existing contacts without nagging (read-only, auto-policy).

Module coverage now:
   todo (5)   calendar (2)   notes (5)   places (4)
   drink (3)  food (2)       news (1)    journal (1)
   habits (3)  news-research (1)  contacts (1)

  11 modules, 28 tools total (17 propose, 11 auto).

Tests: mana-ai 41/41 (drift-guard passes), shared-ai type-check
clean, webapp svelte-check 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 12:25:45 +02:00
parent ae53e93b9a
commit 23b8cc13fb
7 changed files with 253 additions and 2 deletions

View file

@ -12,6 +12,11 @@ export interface Config {
syncDatabaseUrl: string;
/** mana-llm HTTP endpoint (OpenAI-compatible). */
manaLlmUrl: string;
/** Unified mana-api (Hono/Bun, port 3060). Hosts module-specific
* compute endpoints including news-research. Used by the pre-planning
* research step to feed web-research context into the planner prompt
* before it produces plan steps. */
manaApiUrl: string;
/** Shared key for service-to-service calls. */
serviceKey: string;
/** How often the background tick scans for due Missions, in ms. */
@ -49,6 +54,7 @@ export function loadConfig(): Config {
'postgresql://mana:devpassword@localhost:5432/mana_sync'
),
manaLlmUrl: requireEnv('MANA_LLM_URL', 'http://localhost:3020'),
manaApiUrl: requireEnv('MANA_API_URL', 'http://localhost:3060'),
serviceKey: requireEnv('MANA_SERVICE_KEY', 'dev-service-key'),
tickIntervalMs: parseInt(process.env.TICK_INTERVAL_MS ?? '60000', 10),
tickEnabled: process.env.TICK_ENABLED !== 'false',

View file

@ -43,11 +43,18 @@ import {
agentDecisionsTotal,
} from '../metrics';
import { unwrapMissionGrant } from '../crypto/unwrap-grant';
import { NewsResearchClient } from '../planner/news-research-client';
import type { ResolverContext } from '../db/resolvers/types';
import type { Config } from '../config';
const ENC_PREFIX = 'enc:1:';
/** Heuristic: mission objectives that should trigger a pre-planning
* web-research step. Same regex the webapp uses in its reasoning loop
* (`data/ai/missions/runner.ts`). */
const RESEARCH_TRIGGER =
/\b(recherchier|research|news|finde|suche|aktuelle|neueste|today|history|historisch|on this day)/i;
/** True when the value looks like the webapp's AES-GCM wire format. */
function isCiphertext(value: string | undefined): value is string {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
@ -163,7 +170,7 @@ export async function runTickOnce(config: Config): Promise<TickStats> {
}
try {
const plan = await planOneMission(m, planner, sql, agent);
const plan = await planOneMission(m, planner, sql, agent, config);
if (plan === null) {
parseFailures++;
parseFailuresTotal.inc();
@ -225,7 +232,8 @@ async function planOneMission(
m: ServerMission,
planner: PlannerClient,
sql: Sql,
agent: ServerAgent | null
agent: ServerAgent | null,
config: Config
): Promise<AiPlanOutput | null> {
const mission = serverMissionToSharedMission(m);
// Resolve the mission's Key-Grant (if any) once per tick. An absent
@ -237,6 +245,29 @@ async function planOneMission(
// reference goes out of scope and gets GC'd.
const context = await buildResolverContext(m);
const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId, context);
// Pre-planning research step: when the mission objective matches
// research keywords, run RSS discovery + search against mana-api and
// inject the results as a synthetic ResolvedInput. This gives the
// Planner real sources to reference instead of hallucinating URLs.
// Mirrors the webapp's auto-kontext + research pre-step.
if (RESEARCH_TRIGGER.test(m.objective) || RESEARCH_TRIGGER.test(m.conceptMarkdown)) {
const nrc = new NewsResearchClient(config.manaApiUrl);
const research = await nrc.research(m.objective, { language: 'de', limit: 8 });
if (research) {
resolvedInputs.push({
id: '__web-research__',
module: 'news-research',
table: 'web',
title: `Web-Research: "${m.objective.slice(0, 60)}"`,
content: research.contextMarkdown,
});
console.log(
`[mana-ai tick] mission=${m.id} pre-research: ${research.feedCount} feeds, ${research.articles.length} articles`
);
}
}
const input: AiPlanInput = {
mission,
resolvedInputs,

View file

@ -0,0 +1,158 @@
/**
* HTTP client for the mana-api news-research endpoints.
*
* Called by the pre-planning research step in the tick loop: when a
* mission's objective matches research keywords, we discover RSS feeds
* + search them for relevant articles BEFORE the Planner prompt is
* built. The results go into the Planner's resolved-inputs so the LLM
* can reference real sources in its plan instead of hallucinating
* URLs.
*
* Endpoints called:
* POST /api/v1/news-research/discover find RSS feeds for a query
* POST /api/v1/news-research/search rank articles from those feeds
*
* Both live on mana-api (Hono/Bun, port 3060 internally). Service-to-
* service auth is not required on this internal path (same trust
* boundary as mana-llm calls).
*/
export interface DiscoveredFeed {
url: string;
title: string;
description: string;
}
export interface ScoredArticle {
url: string;
title: string;
excerpt: string | null;
publishedAt: string | null;
feedUrl: string;
score: number;
}
export interface NewsResearchResult {
articles: ScoredArticle[];
feedCount: number;
/** Markdown-formatted context string ready for injection into the
* Planner prompt as a ResolvedInput. */
contextMarkdown: string;
}
export class NewsResearchClient {
constructor(private manaApiUrl: string) {}
/**
* Full pipeline: discover feeds for the query, then search + rank
* articles. Returns a ready-to-inject context blob.
*
* Gracefully returns null on any error (network, parse, timeout) so
* a failing mana-api doesn't crash the tick.
*/
async research(
query: string,
opts: { language?: string; limit?: number } = {}
): Promise<NewsResearchResult | null> {
try {
const feeds = await this.discover(query, opts.language ?? 'de');
if (!feeds || feeds.length === 0) return null;
const feedUrls = feeds.map((f) => f.url);
const articles = await this.search(feedUrls, query, opts.limit ?? 10);
if (!articles || articles.length === 0) return null;
const contextMarkdown = formatContext(query, feeds, articles);
return {
articles,
feedCount: feeds.length,
contextMarkdown,
};
} catch (err) {
console.error(
'[news-research-client] research failed:',
err instanceof Error ? err.message : String(err)
);
return null;
}
}
private async discover(query: string, language: string): Promise<DiscoveredFeed[] | null> {
try {
const res = await fetch(`${this.manaApiUrl}/api/v1/news-research/discover`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, language, limit: 5 }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
console.warn(`[news-research-client] discover ${res.status}: ${res.statusText}`);
return null;
}
const body = (await res.json()) as { feeds: DiscoveredFeed[] };
return body.feeds;
} catch (err) {
console.warn(
'[news-research-client] discover error:',
err instanceof Error ? err.message : String(err)
);
return null;
}
}
private async search(
feedUrls: string[],
query: string,
limit: number
): Promise<ScoredArticle[] | null> {
try {
const res = await fetch(`${this.manaApiUrl}/api/v1/news-research/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feeds: feedUrls, query, limit }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
console.warn(`[news-research-client] search ${res.status}: ${res.statusText}`);
return null;
}
const body = (await res.json()) as { articles: ScoredArticle[] };
return body.articles;
} catch (err) {
console.warn(
'[news-research-client] search error:',
err instanceof Error ? err.message : String(err)
);
return null;
}
}
}
/**
* Turn discover + search results into a markdown block the Planner
* prompt can reference. Mirrors the webapp's
* `modules/news-research/tools.ts` formatting.
*/
function formatContext(query: string, feeds: DiscoveredFeed[], articles: ScoredArticle[]): string {
const lines: string[] = [
`# Web-Research: "${query}"`,
'',
`${feeds.length} Quellen durchsucht, ${articles.length} relevante Artikel gefunden:`,
'',
];
for (const a of articles) {
lines.push(`## ${a.title}`);
if (a.excerpt) lines.push(`> ${a.excerpt}`);
lines.push(`URL: ${a.url}`);
if (a.publishedAt) lines.push(`Datum: ${a.publishedAt}`);
lines.push('');
}
lines.push(
'---',
'Nutze diese Quellen fuer deinen Plan. Verwende nur URLs die oben stehen; erfinde keine.'
);
return lines.join('\n');
}

View file

@ -208,6 +208,54 @@ export const AI_AVAILABLE_TOOLS: readonly AvailableTool[] = [
{ name: 'note', type: 'string', description: 'Optionale Notiz zum Log', required: false },
],
},
// ── News-Research ────────────────────────────────────────
{
name: 'research_news',
module: 'news-research',
description:
'Durchsucht RSS-Feeds und Web-Quellen nach relevanten Artikeln zu einem Thema. Gibt gefundene Artikel-URLs + Titel + Zusammenfassung zurueck. Nuetzlich als Vorstufe zu save_news_article.',
parameters: [
{
name: 'query',
type: 'string',
description: 'Suchbegriff / Thema (z.B. "TypeScript 5.8 release")',
required: true,
},
{
name: 'language',
type: 'string',
description: 'Sprache (z.B. "de" oder "en")',
required: false,
},
{
name: 'limit',
type: 'number',
description: 'Max. Anzahl Ergebnisse (Standard: 10)',
required: false,
},
],
},
// ── Contacts ─────────────────────────────────────────────
{
name: 'create_contact',
module: 'contacts',
description: 'Erstellt einen neuen Kontakt. Felder die nicht bekannt sind einfach weglassen.',
parameters: [
{ name: 'firstName', type: 'string', description: 'Vorname', required: true },
{ name: 'lastName', type: 'string', description: 'Nachname', required: false },
{ name: 'email', type: 'string', description: 'E-Mail-Adresse', required: false },
{ name: 'phone', type: 'string', description: 'Telefonnummer', required: false },
{ name: 'company', type: 'string', description: 'Firma / Organisation', required: false },
{
name: 'notes',
type: 'string',
description: 'Freitext-Notizen zum Kontakt',
required: false,
},
],
},
];
export const AI_AVAILABLE_TOOL_NAMES = new Set<string>(AI_AVAILABLE_TOOLS.map((t) => t.name));